Mercurial
changeset 261:b401627fc49e
Add JRPG mock flows and interactive previews
Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Wed, 05 Aug 2026 20:38:32 -0700 |
| parents | 1f9877b637e9 |
| children | 0f45474c1b1a |
| files | .claude/skills/zenbu-personal-site/SKILL.md mrjunejune/BUILD mrjunejune/README.md mrjunejune/conversation_api.c mrjunejune/inference/BUILD mrjunejune/inference/mock_responses.json mrjunejune/inference/mock_sidecar.py mrjunejune/inference/mock_sidecar_test.py mrjunejune/inference_bridge.c mrjunejune/inference_bridge.h mrjunejune/inference_stack.sh mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/sw.js mrjunejune/test/conversation_api_test.js mrjunejune/test/inference_bridge_fake_sidecar.c mrjunejune/test/inference_bridge_test.c mrjunejune/test/production_inference_bundle_test.sh mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/theme_and_webp_test.js |
| diffstat | 21 files changed, 2082 insertions(+), 124 deletions(-) [+] |
line wrap: on
line diff
--- a/.claude/skills/zenbu-personal-site/SKILL.md Wed Aug 05 09:19:41 2026 -0700 +++ b/.claude/skills/zenbu-personal-site/SKILL.md Wed Aug 05 20:38:32 2026 -0700 @@ -87,6 +87,13 @@ bazel run //mrjunejune:run_inference_stack ``` +Use `bazel run //mrjunejune:run_inference_stack -- --mock` for deterministic +UI development through the real Seobeo, Deita, inference bridge, and SSE path +without Copilot authentication or a LiteLLM key. Mock stream scenarios are +maintained as `!command` event scripts in +`mrjunejune/inference/mock_responses.json`; custom event types pass through +Seobeo to the page's `mjj-jrpg-stream-event`. + ## Production-minded checklist - Use Dowa's fixed-width aliases and `boolean`/`TRUE`/`FALSE` in first-party C
--- a/mrjunejune/BUILD Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/BUILD Wed Aug 05 20:38:32 2026 -0700 @@ -326,6 +326,7 @@ "$(location //mrjunejune/inference:litellm_proxy_zip)", "$(location //mrjunejune/inference:litellm_config.yaml)", "$(location @python_3_11//:python3)", + "$(location //mrjunejune/inference:mock_sidecar)", ], data = [ ":mrjunejune_server", @@ -333,6 +334,7 @@ "//mrjunejune/inference:copilot_sidecar_zip", "//mrjunejune/inference:litellm_config.yaml", "//mrjunejune/inference:litellm_proxy_zip", + "//mrjunejune/inference:mock_sidecar", "@copilot_cli_linux_x86_64//:copilot", "@python_3_11//:files", "@python_3_11//:python3",
--- a/mrjunejune/README.md Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/README.md Wed Aug 05 20:38:32 2026 -0700 @@ -28,6 +28,21 @@ bazel run //mrjunejune:run_inference_stack ``` +For UI work, start the same Seobeo, Deita, inference bridge, and SSE path with +deterministic mock responses. This mode does not require Copilot authentication +or a LiteLLM key: + +```bash +bazel run //mrjunejune:run_inference_stack -- --mock +``` + +Send `!hello`, `!response`, `!tool`, `!plan`, or `!error` to run a scripted +event sequence. Edit `mrjunejune/inference/mock_responses.json` to add commands, +ordered SSE event payloads, per-event timing, and response chunks. Custom event +types pass through Seobeo and are published by the page as +`mjj-jrpg-stream-event`, ready for future tool-call and plan-view components. +Mock conversations use a separate database under the inference state directory. + Keep `GITHUB_COPILOT_TOKEN_DIR` exported when starting the stack so it reads the same tokens. Without an override, both commands use `$XDG_STATE_HOME/mrjunejune/inference/litellm-copilot`, or
--- a/mrjunejune/conversation_api.c Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/conversation_api.c Wed Aug 05 20:38:32 2026 -0700 @@ -16,6 +16,8 @@ #define CONVERSATION_PROMPT_MAX (32 * 1024) #define CONVERSATION_RESPONSE_MAX (256 * 1024) #define CONVERSATION_HISTORY_MAX (512 * 1024) +#define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024) +#define CONVERSATION_EVENT_NAME_MAX 64 #define CONVERSATION_ACTIVE_MAX 4 #define CONVERSATION_TURNS_PER_MINUTE 60 @@ -45,6 +47,40 @@ static Pending_Turn *g_pending_turns = NULL; +static boolean Conversation_API_Is_Handled_Event(const char *type) +{ + return + strcmp(type, "ready") == 0 || + strcmp(type, "turn.accepted") == 0 || + strcmp(type, "assistant.delta") == 0 || + strcmp(type, "assistant.completed") == 0 || + strcmp(type, "assistant.usage") == 0 || + strcmp(type, "turn.error") == 0 || + strcmp(type, "turn.done") == 0; +} + +static boolean Conversation_API_Is_Safe_Event_Name(const char *type) +{ + if (!type || type[0] == '\0') + return FALSE; + size_t length = strlen(type); + if (length > CONVERSATION_EVENT_NAME_MAX) + return FALSE; + for (size_t i = 0; i < length; i++) + { + char character = type[i]; + if (!( + (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '.' || + character == '_' || + character == '-')) + return FALSE; + } + return TRUE; +} + static boolean Conversation_API_Acquire_Turn_Slot(void) { time_t now = time(NULL); @@ -316,6 +352,48 @@ return; } + if (!Conversation_API_Is_Handled_Event(p_event->type)) + { + if (!Conversation_API_Is_Safe_Event_Name(p_event->type) || + !p_event->raw_json || + p_event->raw_json_length == 0 || + p_event->raw_json_length > CONVERSATION_CUSTOM_EVENT_MAX) + { + p_turn->failed = TRUE; + snprintf( + p_turn->error_message, + sizeof(p_turn->error_message), + "Invalid custom inference event"); + Conversation_API_Send_Event( + p_turn, + "turn.error", + "{\"code\":\"invalid_custom_event\"," + "\"message\":\"Invalid custom inference event\"}"); + Conversation_API_Finalize_Pending(p_turn); + } + else if (Conversation_API_Send_Event( + p_turn, p_event->type, p_event->raw_json) < 0) + { + p_turn->aborted = TRUE; + should_abort = TRUE; + snprintf( + abort_request_id, + sizeof(abort_request_id), + "%s", + p_turn->request_id); + snprintf( + abort_conversation_id, + sizeof(abort_conversation_id), + "%s", + p_turn->conversation_id); + } + pthread_mutex_unlock(&g_pending_mutex); + if (should_abort) + Inference_Bridge_Abort_Turn( + g_inference_bridge, abort_request_id, abort_conversation_id); + return; + } + size_t event_text_length = strlen(p_event->delta ? p_event->delta : "") + strlen(p_event->content ? p_event->content : "") +
--- a/mrjunejune/inference/BUILD Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/inference/BUILD Wed Aug 05 20:38:32 2026 -0700 @@ -46,6 +46,13 @@ visibility = ["//mrjunejune:__pkg__"], ) +py_binary( + name = "mock_sidecar", + srcs = ["mock_sidecar.py"], + data = ["mock_responses.json"], + visibility = ["//mrjunejune:__pkg__"], +) + python_zip_file( name = "copilot_sidecar_zip", binary = ":copilot_sidecar", @@ -100,6 +107,16 @@ ) py_test( + name = "mock_sidecar_test", + srcs = [ + "mock_sidecar.py", + "mock_sidecar_test.py", + ], + data = ["mock_responses.json"], + main = "mock_sidecar_test.py", +) + +py_test( name = "sdk_runtime_test", srcs = ["sdk_runtime_test.py"], args = ["$(rootpath @copilot_cli_linux_x86_64//:copilot)"],
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/inference/mock_responses.json Wed Aug 05 20:38:32 2026 -0700 @@ -0,0 +1,126 @@ +{ + "delay_ms": 55, + "fallback": "!response", + "commands": { + "!hello": { + "description": "Short greeting with exact chunks.", + "events": [ + { + "type": "assistant.delta", + "delta": "Hello, " + }, + { + "type": "assistant.delta", + "delta": "traveler." + }, + { + "type": "assistant.completed", + "content": "Hello, traveler." + } + ] + }, + "!response": { + "description": "Long response for typing, wrapping, and scrolling.", + "events": [ + { + "type": "assistant.delta", + "delta": "The neon trail stretches across the terminal while " + }, + { + "type": "assistant.delta", + "delta": "each word arrives through the real Seobeo SSE path. " + }, + { + "type": "assistant.delta", + "delta": "Edit mock_responses.json to change this sequence." + }, + { + "type": "assistant.completed", + "content": "The neon trail stretches across the terminal while each word arrives through the real Seobeo SSE path. Edit mock_responses.json to change this sequence." + } + ] + }, + "!tool": { + "description": "Example tool lifecycle for future tool-call UI.", + "events": [ + { + "type": "tool.started", + "tool": { + "id": "mock-tool-1", + "name": "site_search", + "arguments": { + "query": "JRPG design notes" + } + } + }, + { + "type": "tool.completed", + "delay_ms": 350, + "tool": { + "id": "mock-tool-1", + "name": "site_search", + "result": { + "matches": 3 + } + } + }, + { + "type": "assistant.delta", + "delta": "The mock search found three matching entries." + }, + { + "type": "assistant.completed", + "content": "The mock search found three matching entries." + } + ] + }, + "!plan": { + "description": "Example plan event for a future plan-view component.", + "events": [ + { + "type": "plan.updated", + "plan": { + "title": "Tune the JRPG chat", + "steps": [ + { + "id": "layout", + "label": "Adjust layout", + "status": "completed" + }, + { + "id": "motion", + "label": "Review stream motion", + "status": "in_progress" + }, + { + "id": "details", + "label": "Polish tool details", + "status": "pending" + } + ] + } + }, + { + "type": "assistant.delta", + "delta": "I updated the mock implementation plan." + }, + { + "type": "assistant.completed", + "content": "I updated the mock implementation plan." + } + ] + }, + "!error": { + "description": "Failed turn treatment.", + "events": [ + { + "type": "turn.error", + "error": { + "code": "mock_failure", + "message": "The mock crystal was asked to fail." + } + } + ] + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/inference/mock_sidecar.py Wed Aug 05 20:38:32 2026 -0700 @@ -0,0 +1,526 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import pathlib +import re +import sys +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + + +JsonObject = dict[str, Any] +Emit = Callable[[JsonObject], Awaitable[None]] +COMMAND_PATTERN = re.compile(r"^![a-z][a-z0-9_-]*$") +EVENT_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +RESERVED_EVENT_TYPES = {"bridge.closed", "ready", "turn.accepted", "turn.done"} +RESERVED_EVENT_FIELDS = { + "conversation_id", + "mock", + "mock_command", + "request_id", +} +MAX_DELAY_MS = 60_000 + + +@dataclass(frozen=True) +class MockEvent: + event_type: str + delay_ms: int | None + payload: JsonObject + + +@dataclass(frozen=True) +class MockCommand: + name: str + description: str + events: tuple[MockEvent, ...] + failed: bool + + +@dataclass(frozen=True) +class MockConfig: + delay_ms: int + fallback: str + commands: dict[str, MockCommand] + + def select(self, prompt: str) -> MockCommand: + first_token = prompt.strip().split(maxsplit=1)[0].lower() + return self.commands.get(first_token, self.commands[self.fallback]) + + +@dataclass +class MockTurn: + request_id: str + task: asyncio.Task[None] + + +def _require_non_negative_integer(value: Any, description: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"{description} must be a non-negative integer") + if value > MAX_DELAY_MS: + raise ValueError(f"{description} must not exceed {MAX_DELAY_MS}") + return value + + +def _validate_event(command_name: str, index: int, raw: Any) -> MockEvent: + if not isinstance(raw, dict): + raise ValueError(f"{command_name} event {index} must be an object") + event_type = raw.get("type") + if not isinstance(event_type, str) or not EVENT_PATTERN.fullmatch(event_type): + raise ValueError( + f"{command_name} event {index} requires a safe event type" + ) + if event_type in RESERVED_EVENT_TYPES: + raise ValueError( + f"{command_name} cannot script lifecycle event {event_type}" + ) + reserved = set(raw) & RESERVED_EVENT_FIELDS + if reserved: + raise ValueError( + f"{command_name} event {index} uses reserved fields: " + f"{', '.join(sorted(reserved))}" + ) + delay_ms = raw.get("delay_ms") + if delay_ms is not None: + delay_ms = _require_non_negative_integer( + delay_ms, + f"{command_name} event {index} delay_ms", + ) + payload = { + key: value + for key, value in raw.items() + if key not in {"delay_ms", "type"} + } + if event_type == "assistant.delta": + if not isinstance(payload.get("delta"), str) or not payload["delta"]: + raise ValueError( + f"{command_name} assistant.delta requires non-empty delta" + ) + elif event_type == "assistant.completed": + if not isinstance(payload.get("content"), str): + raise ValueError( + f"{command_name} assistant.completed requires content" + ) + elif event_type == "assistant.usage": + usage = payload.get("usage") + if not isinstance(usage, dict): + raise ValueError(f"{command_name} assistant.usage requires usage") + for key in ("input_tokens", "output_tokens"): + _require_non_negative_integer( + usage.get(key), + f"{command_name} assistant.usage {key}", + ) + elif event_type == "turn.error": + error = payload.get("error") + if not isinstance(error, dict): + raise ValueError(f"{command_name} turn.error requires error") + if ( + not isinstance(error.get("code"), str) + or not error["code"] + or not isinstance(error.get("message"), str) + or not error["message"] + ): + raise ValueError( + f"{command_name} turn.error requires error code and message" + ) + return MockEvent(event_type, delay_ms, payload) + + +def _validate_command(name: str, raw: Any) -> MockCommand: + if not COMMAND_PATTERN.fullmatch(name): + raise ValueError(f"invalid mock command: {name}") + if not isinstance(raw, dict): + raise ValueError(f"{name} must contain an object") + unknown = set(raw) - {"description", "events"} + if unknown: + raise ValueError( + f"{name} has unknown keys: {', '.join(sorted(unknown))}" + ) + description = raw.get("description", "") + if not isinstance(description, str): + raise ValueError(f"{name} description must be a string") + raw_events = raw.get("events") + if not isinstance(raw_events, list) or not raw_events: + raise ValueError(f"{name} events must be a non-empty array") + if len(raw_events) > 256: + raise ValueError(f"{name} has too many events") + events = tuple( + _validate_event(name, index, event) + for index, event in enumerate(raw_events) + ) + errors = [event for event in events if event.event_type == "turn.error"] + completed = [ + event for event in events if event.event_type == "assistant.completed" + ] + if errors: + if len(errors) != 1 or errors[0] is not events[-1]: + raise ValueError(f"{name} turn.error must be the final scripted event") + if completed: + raise ValueError(f"{name} cannot complete and fail the same turn") + elif len(completed) != 1: + raise ValueError(f"{name} requires one assistant.completed event") + + deltas = "".join( + event.payload["delta"] + for event in events + if event.event_type == "assistant.delta" + ) + if completed: + completed_index = events.index(completed[0]) + if any( + event.event_type == "assistant.delta" + for event in events[completed_index + 1:] + ): + raise ValueError( + f"{name} assistant.completed must follow all deltas" + ) + if completed and deltas and deltas != completed[0].payload["content"]: + raise ValueError(f"{name} deltas must join to completed content") + return MockCommand(name, description, events, failed=bool(errors)) + + +def load_mock_config(path: str | os.PathLike[str]) -> MockConfig: + with open(path, encoding="utf-8") as config_file: + payload = json.load(config_file) + if not isinstance(payload, dict): + raise ValueError("mock response file must contain a JSON object") + unknown = set(payload) - {"commands", "delay_ms", "fallback"} + if unknown: + raise ValueError(f"unknown mock config keys: {', '.join(sorted(unknown))}") + delay_ms = _require_non_negative_integer( + payload.get("delay_ms", 55), + "delay_ms", + ) + fallback = payload.get("fallback") + if not isinstance(fallback, str) or not COMMAND_PATTERN.fullmatch(fallback): + raise ValueError("fallback must be a !command") + raw_commands = payload.get("commands") + if not isinstance(raw_commands, dict) or not raw_commands: + raise ValueError("commands must be a non-empty object") + commands = { + name.lower(): _validate_command(name.lower(), raw) + for name, raw in raw_commands.items() + } + if fallback.lower() not in commands: + raise ValueError(f"fallback command is not defined: {fallback}") + return MockConfig(delay_ms, fallback.lower(), commands) + + +class MockSidecar: + def __init__(self, emit: Emit, config: MockConfig) -> None: + self._emit = emit + self._config = config + self._active: dict[str, MockTurn] = {} + self.shutting_down = False + + async def announce_ready(self) -> None: + await self._send("ready", None, None, status="ok", mock=True) + + async def dispatch(self, command: JsonObject) -> None: + command_name = command.get("command") + request_id = command.get("request_id") + conversation_id = command.get("conversation_id") + if not isinstance(request_id, str) or not request_id: + await self._fail( + request_id if isinstance(request_id, str) else None, + conversation_id if isinstance(conversation_id, str) else None, + "invalid_request", + "request_id is required", + ) + return + if command_name == "shutdown": + await self._shutdown(request_id, conversation_id) + return + if command_name == "health": + await self._send( + "ready", + request_id, + conversation_id if isinstance(conversation_id, str) else None, + status="ok", + mock=True, + ) + return + if not isinstance(conversation_id, str) or not conversation_id: + await self._fail( + request_id, + None, + "invalid_request", + "conversation_id is required", + ) + return + if command_name == "turn.start": + prompt = command.get("prompt") + if not isinstance(prompt, str) or not prompt: + await self._fail( + request_id, + conversation_id, + "invalid_request", + "prompt is required", + ) + return + await self._start_turn(request_id, conversation_id, prompt) + elif command_name == "turn.abort": + await self._abort_turn(request_id, conversation_id) + elif command_name == "conversation.delete": + await self._delete_conversation(request_id, conversation_id) + else: + await self._fail( + request_id, + conversation_id, + "unknown_command", + f"unsupported command: {command_name!r}", + ) + + async def wait_for_idle(self) -> None: + tasks = [turn.task for turn in self._active.values()] + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + async def _start_turn( + self, + request_id: str, + conversation_id: str, + prompt: str, + ) -> None: + if conversation_id in self._active: + await self._fail( + request_id, + conversation_id, + "turn_in_progress", + "the conversation already has an active turn", + ) + return + scripted_command = self._config.select(prompt) + await self._send( + "turn.accepted", + request_id, + conversation_id, + mock=True, + mock_command=scripted_command.name, + ) + task = asyncio.create_task( + self._stream_turn(request_id, conversation_id, scripted_command) + ) + self._active[conversation_id] = MockTurn(request_id, task) + + async def _stream_turn( + self, + request_id: str, + conversation_id: str, + scripted_command: MockCommand, + ) -> None: + try: + for event in scripted_command.events: + delay = ( + self._config.delay_ms + if event.delay_ms is None + else event.delay_ms + ) + await asyncio.sleep(delay / 1000) + await self._send( + event.event_type, + request_id, + conversation_id, + **event.payload, + mock=True, + mock_command=scripted_command.name, + ) + await self._send( + "turn.done", + request_id, + conversation_id, + failed=scripted_command.failed, + mock=True, + mock_command=scripted_command.name, + ) + except asyncio.CancelledError: + await self._send( + "turn.done", + request_id, + conversation_id, + aborted=True, + mock=True, + mock_command=scripted_command.name, + ) + except Exception as error: + await self._send( + "turn.error", + request_id, + conversation_id, + error={ + "code": "mock_script_failed", + "message": str(error), + }, + mock_command=scripted_command.name, + ) + await self._send( + "turn.done", + request_id, + conversation_id, + failed=True, + mock=True, + mock_command=scripted_command.name, + ) + finally: + active = self._active.get(conversation_id) + if active is not None and active.request_id == request_id: + self._active.pop(conversation_id, None) + + async def _abort_turn( + self, + request_id: str, + conversation_id: str, + ) -> None: + active = self._active.get(conversation_id) + if active is None: + await self._fail( + request_id, + conversation_id, + "no_active_turn", + "no active turn to abort", + ) + return + await self._send( + "turn.accepted", + request_id, + conversation_id, + action="abort", + target_request_id=active.request_id, + mock=True, + ) + active.task.cancel() + await asyncio.gather(active.task, return_exceptions=True) + await self._send( + "turn.done", + request_id, + conversation_id, + action="abort", + target_request_id=active.request_id, + mock=True, + ) + + async def _delete_conversation( + self, + request_id: str, + conversation_id: str, + ) -> None: + active = self._active.get(conversation_id) + if active is not None: + active.task.cancel() + await asyncio.gather(active.task, return_exceptions=True) + await self._send( + "turn.done", + request_id, + conversation_id, + action="conversation.delete", + mock=True, + ) + + async def _shutdown( + self, + request_id: str, + conversation_id: Any, + ) -> None: + self.shutting_down = True + tasks = [turn.task for turn in self._active.values()] + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + await self._send( + "turn.done", + request_id, + conversation_id if isinstance(conversation_id, str) else None, + action="shutdown", + mock=True, + ) + + async def _fail( + self, + request_id: str | None, + conversation_id: str | None, + code: str, + message: str, + ) -> None: + await self._send( + "turn.error", + request_id, + conversation_id, + error={"code": code, "message": message}, + ) + await self._send( + "turn.done", + request_id, + conversation_id, + failed=True, + mock=True, + ) + + async def _send( + self, + event_type: str, + request_id: str | None, + conversation_id: str | None, + **fields: Any, + ) -> None: + await self._emit( + { + "type": event_type, + "request_id": request_id, + "conversation_id": conversation_id, + **fields, + } + ) + + +async def run(config: MockConfig) -> None: + async def emit(payload: JsonObject) -> None: + sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n") + sys.stdout.flush() + + sidecar = MockSidecar(emit, config) + await sidecar.announce_ready() + while not sidecar.shutting_down: + line = await asyncio.to_thread(sys.stdin.readline) + if not line: + break + try: + command = json.loads(line) + if not isinstance(command, dict): + raise ValueError("command must be a JSON object") + except (json.JSONDecodeError, ValueError) as error: + await sidecar._fail(None, None, "invalid_json", str(error)) + continue + await sidecar.dispatch(command) + await sidecar.wait_for_idle() + + +def main() -> None: + packaged_responses = pathlib.Path(__file__).with_name("mock_responses.json") + parser = argparse.ArgumentParser(description="Scripted JRPG mock sidecar") + parser.add_argument("copilot_cli", nargs="?") + parser.add_argument("--responses", default=str(packaged_responses)) + args = parser.parse_args() + responses_path = os.environ.get("MRJUNEJUNE_MOCK_RESPONSES") or args.responses + config = load_mock_config(responses_path) + delay_override = os.environ.get("MRJUNEJUNE_MOCK_DELAY_MS") + if delay_override is not None: + try: + delay_ms = int(delay_override) + except ValueError as error: + raise ValueError( + "MRJUNEJUNE_MOCK_DELAY_MS must be a non-negative integer" + ) from error + delay_ms = _require_non_negative_integer( + delay_ms, + "MRJUNEJUNE_MOCK_DELAY_MS", + ) + config = MockConfig(delay_ms, config.fallback, config.commands) + asyncio.run(run(config)) + + +if __name__ == "__main__": + main()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/inference/mock_sidecar_test.py Wed Aug 05 20:38:32 2026 -0700 @@ -0,0 +1,228 @@ +import asyncio +import json +import pathlib +import tempfile +import unittest + +from mrjunejune.inference.mock_sidecar import ( + MockConfig, + MockSidecar, + load_mock_config, +) + + +class MockSidecarTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.events = [] + + async def emit(payload): + self.events.append(payload) + + packaged = load_mock_config( + pathlib.Path(__file__).with_name("mock_responses.json") + ) + self.config = MockConfig( + delay_ms=0, + fallback=packaged.fallback, + commands=packaged.commands, + ) + self.sidecar = MockSidecar(emit, self.config) + + def test_packaged_commands_are_selectable(self): + self.assertEqual(self.config.select("!hello").name, "!hello") + self.assertEqual(self.config.select("!response please").name, "!response") + self.assertEqual(self.config.select("ordinary prompt").name, "!response") + self.assertEqual(self.config.select("!tool").name, "!tool") + self.assertEqual(self.config.select("!plan").name, "!plan") + + def test_response_file_rejects_invalid_scripts(self): + invalid_configs = ( + { + "fallback": "!hello", + "commands": { + "!hello": { + "events": [ + { + "type": "assistant.completed", + "content": "Hello", + } + ], + "unknown": True, + } + }, + }, + { + "fallback": "hello", + "commands": { + "hello": { + "events": [ + { + "type": "assistant.completed", + "content": "Hello", + } + ] + } + }, + }, + { + "fallback": "!hello", + "commands": { + "!hello": { + "events": [ + { + "type": "assistant.delta", + "delta": "Different", + }, + { + "type": "assistant.completed", + "content": "Hello", + }, + ] + } + }, + }, + { + "delay_ms": 60001, + "fallback": "!hello", + "commands": { + "!hello": { + "events": [ + { + "type": "assistant.completed", + "content": "Hello", + } + ] + } + }, + }, + { + "fallback": "!hello", + "commands": { + "!hello": { + "events": [ + { + "type": "assistant.completed", + "content": "Hello", + }, + { + "type": "assistant.delta", + "delta": "Hello", + }, + ] + } + }, + }, + ) + for payload in invalid_configs: + with self.subTest(payload=payload): + with tempfile.TemporaryDirectory() as root: + path = pathlib.Path(root) / "responses.json" + path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaises(ValueError): + load_mock_config(path) + + async def test_hello_command_streams_scripted_events(self): + await self.sidecar.dispatch( + { + "command": "turn.start", + "request_id": "request-1", + "conversation_id": "conversation-1", + "prompt": "!hello", + } + ) + await self.sidecar.wait_for_idle() + + self.assertEqual( + [event["type"] for event in self.events], + [ + "turn.accepted", + "assistant.delta", + "assistant.delta", + "assistant.completed", + "turn.done", + ], + ) + deltas = "".join( + event["delta"] + for event in self.events + if event["type"] == "assistant.delta" + ) + completed = next( + event["content"] + for event in self.events + if event["type"] == "assistant.completed" + ) + self.assertEqual(deltas, completed) + self.assertEqual(self.events[-1]["mock_command"], "!hello") + + async def test_tool_command_preserves_custom_event_payloads(self): + await self.sidecar.dispatch( + { + "command": "turn.start", + "request_id": "request-2", + "conversation_id": "conversation-2", + "prompt": "!tool", + } + ) + await self.sidecar.wait_for_idle() + + started = next( + event for event in self.events if event["type"] == "tool.started" + ) + completed = next( + event for event in self.events if event["type"] == "tool.completed" + ) + self.assertEqual(started["tool"]["name"], "site_search") + self.assertEqual(completed["tool"]["result"]["matches"], 3) + + async def test_error_command_emits_failed_turn(self): + await self.sidecar.dispatch( + { + "command": "turn.start", + "request_id": "request-3", + "conversation_id": "conversation-3", + "prompt": "!error", + } + ) + await self.sidecar.wait_for_idle() + + self.assertEqual( + [event["type"] for event in self.events], + ["turn.accepted", "turn.error", "turn.done"], + ) + self.assertTrue(self.events[-1]["failed"]) + + async def test_abort_stops_active_command(self): + self.sidecar._config = MockConfig( + delay_ms=50, + fallback=self.config.fallback, + commands=self.config.commands, + ) + await self.sidecar.dispatch( + { + "command": "turn.start", + "request_id": "request-4", + "conversation_id": "conversation-4", + "prompt": "!response", + } + ) + await asyncio.sleep(0.01) + await self.sidecar.dispatch( + { + "command": "turn.abort", + "request_id": "abort-4", + "conversation_id": "conversation-4", + } + ) + + target_done = next( + event + for event in self.events + if event["type"] == "turn.done" + and event["request_id"] == "request-4" + ) + self.assertTrue(target_done["aborted"]) + + +if __name__ == "__main__": + unittest.main()
--- a/mrjunejune/inference_bridge.c Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/inference_bridge.c Wed Aug 05 20:38:32 2026 -0700 @@ -99,6 +99,8 @@ .conversation_id = Inference_JSON_String(object, "conversation_id"), .delta = Inference_JSON_String(object, "delta"), .content = Inference_JSON_String(object, "content"), + .raw_json = line, + .raw_json_length = length, .failed = Inference_JSON_Boolean(object, "failed"), .aborted = Inference_JSON_Boolean(object, "aborted"), }; @@ -146,7 +148,10 @@ (line[length - 1] == '\n' || line[length - 1] == '\r')) length--; if (length > 0) + { + line[length] = '\0'; Inference_Bridge_Handle_Line(p_bridge, line, length); + } } free(line); atomic_store(&p_bridge->ready, FALSE);
--- a/mrjunejune/inference_bridge.h Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/inference_bridge.h Wed Aug 05 20:38:32 2026 -0700 @@ -13,6 +13,8 @@ const char *content; const char *error_code; const char *error_message; + const char *raw_json; + size_t raw_json_length; int64 input_tokens; int64 output_tokens; boolean failed;
--- a/mrjunejune/inference_stack.sh Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/inference_stack.sh Wed Aug 05 20:38:32 2026 -0700 @@ -8,24 +8,27 @@ litellm_zip="$(realpath "$5")" litellm_config="$(realpath "$6")" python="$(realpath "$7")" +mock_sidecar="${8:-}" +if (( $# >= 8 )); then + shift 8 +else + shift 7 +fi -: "${LITELLM_MASTER_KEY:?LITELLM_MASTER_KEY must be set}" +mode=live +if [[ "${1:-}" == "--mock" ]]; then + mode=mock + shift +fi +if (( $# != 0 )); then + echo "Usage: bazel run //mrjunejune:run_inference_stack -- [--mock]" >&2 + exit 2 +fi litellm_host="${LITELLM_HOST:-127.0.0.1}" litellm_port="${LITELLM_PORT:-4000}" default_state_root="${XDG_STATE_HOME:-$HOME/.local/state}/mrjunejune/inference" state_root="${MRJUNEJUNE_INFERENCE_STATE:-$default_state_root}" -token_dir="${GITHUB_COPILOT_TOKEN_DIR:-$state_root/litellm-copilot}" -mkdir -p "$state_root/copilot" "$token_dir" -chmod 700 "$state_root" "$state_root/copilot" "$token_dir" - -export GITHUB_COPILOT_TOKEN_DIR="$token_dir" -if [[ ! -s "$GITHUB_COPILOT_TOKEN_DIR/access-token" ]]; then - echo "GitHub Copilot is not authenticated for LiteLLM." >&2 - echo "Run: bazel run //mrjunejune/inference:litellm_proxy -- --authenticate --token-dir \"$GITHUB_COPILOT_TOKEN_DIR\"" >&2 - exit 1 -fi - litellm_pid= server_pid= @@ -51,47 +54,78 @@ } trap cleanup EXIT INT TERM -setsid "$python" "$litellm_zip" \ - --config "$litellm_config" \ - --host "$litellm_host" \ - --port "$litellm_port" & -litellm_pid=$! - -for _ in $(seq 1 150); do - if ! kill -0 "$litellm_pid" 2>/dev/null; then - echo "LiteLLM exited before becoming ready" >&2 +if [[ "$mode" == "mock" ]]; then + if [[ -z "$mock_sidecar" ]]; then + echo "Mock sidecar is unavailable in this runtime" >&2 exit 1 fi - if curl --fail --silent \ + mock_state="${MRJUNEJUNE_MOCK_STATE:-$state_root/mock}" + mkdir -p "$mock_state" + chmod 700 "$mock_state" + export MRJUNEJUNE_DB_PATH="${MRJUNEJUNE_DB_PATH:-$mock_state/conversations.db}" + if [[ "$mock_sidecar" == /* ]]; then + export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$mock_sidecar" + else + export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$PWD/$mock_sidecar" + fi + export MRJUNEJUNE_COPILOT_CLI_PATH="$MRJUNEJUNE_INFERENCE_SIDECAR_PATH" + export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE=1 + echo "Starting mock inference at http://127.0.0.1:${MRJUNEJUNE_PORT:-6969}/jrpg" +else + : "${LITELLM_MASTER_KEY:?LITELLM_MASTER_KEY must be set}" + token_dir="${GITHUB_COPILOT_TOKEN_DIR:-$state_root/litellm-copilot}" + mkdir -p "$state_root/copilot" "$token_dir" + chmod 700 "$state_root" "$state_root/copilot" "$token_dir" + + export GITHUB_COPILOT_TOKEN_DIR="$token_dir" + if [[ ! -s "$GITHUB_COPILOT_TOKEN_DIR/access-token" ]]; then + echo "GitHub Copilot is not authenticated for LiteLLM." >&2 + echo "Run: bazel run //mrjunejune/inference:litellm_proxy -- --authenticate --token-dir \"$GITHUB_COPILOT_TOKEN_DIR\"" >&2 + exit 1 + fi + + setsid "$python" "$litellm_zip" \ + --config "$litellm_config" \ + --host "$litellm_host" \ + --port "$litellm_port" & + litellm_pid=$! + + for _ in $(seq 1 150); do + if ! kill -0 "$litellm_pid" 2>/dev/null; then + echo "LiteLLM exited before becoming ready" >&2 + exit 1 + fi + if curl --fail --silent \ + --connect-timeout 1 \ + --max-time 2 \ + "http://${litellm_host}:${litellm_port}/health/readiness" \ + >/dev/null; then + break + fi + sleep 0.1 + done + + if ! curl --fail --silent \ --connect-timeout 1 \ --max-time 2 \ "http://${litellm_host}:${litellm_port}/health/readiness" \ >/dev/null; then - break + echo "LiteLLM readiness timed out" >&2 + exit 1 fi - sleep 0.1 -done -if ! curl --fail --silent \ - --connect-timeout 1 \ - --max-time 2 \ - "http://${litellm_host}:${litellm_port}/health/readiness" \ - >/dev/null; then - echo "LiteLLM readiness timed out" >&2 - exit 1 + export MRJUNEJUNE_PYTHON_PATH="$python" + export MRJUNEJUNE_SIDECAR_ZIP="$sidecar_zip" + export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$sidecar_launcher" + export MRJUNEJUNE_COPILOT_CLI_PATH="$copilot_cli" + export COPILOT_SIDECAR_HOME="$state_root/copilot" + export LITELLM_BASE_URL="http://${litellm_host}:${litellm_port}/v1" + export LITELLM_MODEL="${LITELLM_MODEL:-jrpg-copilot}" + export LITELLM_WIRE_API="${LITELLM_WIRE_API:-completions}" + export LITELLM_API_KEY="$LITELLM_MASTER_KEY" + export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE="${MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE:-0}" fi -export MRJUNEJUNE_PYTHON_PATH="$python" -export MRJUNEJUNE_SIDECAR_ZIP="$sidecar_zip" -export MRJUNEJUNE_INFERENCE_SIDECAR_PATH="$sidecar_launcher" -export MRJUNEJUNE_COPILOT_CLI_PATH="$copilot_cli" -export COPILOT_SIDECAR_HOME="$state_root/copilot" -export LITELLM_BASE_URL="http://${litellm_host}:${litellm_port}/v1" -export LITELLM_MODEL="${LITELLM_MODEL:-jrpg-copilot}" -export LITELLM_WIRE_API="${LITELLM_WIRE_API:-completions}" -export LITELLM_API_KEY="$LITELLM_MASTER_KEY" -export MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE="${MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE:-0}" - setsid "$server" & server_pid=$! while true; do @@ -102,7 +136,7 @@ set -e exit "$server_status" fi - if ! kill -0 "$litellm_pid" 2>/dev/null; then + if [[ -n "$litellm_pid" ]] && ! kill -0 "$litellm_pid" 2>/dev/null; then wait "$litellm_pid" 2>/dev/null || true echo "LiteLLM exited while the public server was running" >&2 exit 1
--- a/mrjunejune/src/jrpg/index.html Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/src/jrpg/index.html Wed Aug 05 20:38:32 2026 -0700 @@ -18,7 +18,6 @@ <mjj-jrpg-chat aria-labelledby="conversation-title"> <div class="jrpg-panel-heading"> - <span aria-hidden="true">QUEST 01</span> <h1 id="conversation-title">Conversations</h1> </div> <zen-message-scroller> @@ -27,23 +26,35 @@ data-zen-viewport role="log" aria-live="polite" - aria-relevant="additions" + aria-relevant="additions text" > <ol class="jrpg-messages" data-messages> - <li class="jrpg-message" data-speaker="Epi"> + <li class="jrpg-message" data-speaker="Epi" data-turn-group="start"> <strong>Epi</strong> <p>Welcome, traveler. Pick a destination or tell me what you are looking for.</p> </li> - <li class="jrpg-message" data-speaker="System"> + <li class="jrpg-message" data-speaker="System" data-turn-group="start"> <strong>System</strong> <p>The Shiba terminal is ready.</p> </li> </ol> </div> </zen-message-scroller> - <p class="jrpg-next" aria-hidden="true"> - NEXT <zen-icon name="chevron-right" size="0.9em"></zen-icon> - </p> + <nav class="jrpg-turn-navigation" aria-label="Conversation turns"> + <zen-button appearance="plain" size="sm"> + <button type="button" data-turn-previous aria-label="Previous turn" disabled> + <zen-icon name="chevron-left"></zen-icon> + Previous + </button> + </zen-button> + <span data-turn-position aria-live="polite">START</span> + <zen-button appearance="plain" size="sm"> + <button type="button" data-turn-next aria-label="Next turn" disabled> + Next + <zen-icon name="chevron-right"></zen-icon> + </button> + </zen-button> + </nav> </mjj-jrpg-chat> </section> @@ -64,6 +75,20 @@ <h2 data-preview-title>Resume</h2> </zen-heading> <p data-preview-copy>Experience, projects, and the systems I have helped build.</p> + <ul class="jrpg-work-showcase" data-work-showcase> + <li> + <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/"> + <span>Copilot Tasks</span> + <small>Agentic execution</small> + </a> + </li> + <li> + <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html"> + <span>Copilot SuperApp</span> + <small>AI platform</small> + </a> + </li> + </ul> <dl class="jrpg-preview-stats"> <div> <dt>TYPE</dt> @@ -82,7 +107,7 @@ <zen-icon name="search"></zen-icon> </button> </zen-button> - <dialog aria-labelledby="jrpg-dialog-title"> + <dialog class="jrpg-resume-dialog" aria-labelledby="jrpg-dialog-title"> <div class="jrpg-dialog-heading"> <p>DESTINATION DATA</p> <zen-button appearance="plain" size="md"> @@ -93,12 +118,28 @@ </div> <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2> <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p> - <zen-button appearance="plain" size="lg"> - <a href="/resume" data-preview-link> - Open destination - <zen-icon name="arrow-right"></zen-icon> - </a> - </zen-button> + <section + class="jrpg-resume-dossier" + data-resume-dossier + aria-label="Resume dossier" + > + <p data-resume-status>Loading resume dossier...</p> + <div data-resume-content></div> + </section> + <div class="jrpg-dialog-actions"> + <zen-button appearance="plain" size="lg"> + <a href="/resume" data-preview-link> + Open full page + <zen-icon name="arrow-right"></zen-icon> + </a> + </zen-button> + <zen-button appearance="plain" size="lg" data-resume-download> + <a href="/public/resume.pdf"> + Download PDF + <zen-icon name="download"></zen-icon> + </a> + </zen-button> + </div> </dialog> </zen-dialog> </article> @@ -118,18 +159,20 @@ required ></textarea> </zen-field> - <zen-button appearance="plain" size="lg"> - <button type="submit"> - Send - <zen-icon name="arrow-right"></zen-icon> - </button> - </zen-button> - <zen-button appearance="plain" size="lg" data-cancel-control hidden> - <button type="button" data-cancel> - Cancel - <zen-icon name="close"></zen-icon> - </button> - </zen-button> + <div class="jrpg-composer-actions"> + <zen-button appearance="plain" size="lg"> + <button type="submit"> + Send + <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="lg" data-cancel-control hidden> + <button type="button" data-cancel> + Cancel + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </div> <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p> </form> </mjj-jrpg-composer>
--- a/mrjunejune/src/jrpg/jrpg.css Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.css Wed Aug 05 20:38:32 2026 -0700 @@ -177,7 +177,6 @@ border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); } -.jrpg-panel-heading span, .jrpg-preview-kicker, .jrpg-menu-label, .jrpg-brand > p, @@ -194,14 +193,18 @@ font-size: var(--zenbu-sys-font-size-lg); } -zen-message-scroller, -.jrpg-message-viewport { +zen-message-scroller { display: block; + overflow: hidden; min-height: 0; height: 100%; } -.jrpg-message-viewport { +zen-message-scroller > .jrpg-message-viewport { + box-sizing: border-box; + height: 100%; + min-height: 0; + max-height: none; overflow-y: auto; overflow-anchor: none; overscroll-behavior: contain; @@ -224,6 +227,10 @@ border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent); } +.jrpg-message[hidden] { + display: none; +} + .jrpg-message[data-speaker="June"] { border-left-color: var(--mjj-jrpg-info); } @@ -236,16 +243,75 @@ .jrpg-message p { margin: 0; color: var(--mjj-jrpg-text); + white-space: pre-wrap; +} + +.jrpg-message[data-streaming="true"] p::after { + display: inline-block; + width: 0.65em; + height: 1em; + margin-left: var(--zenbu-sys-space-icon-label); + background: currentColor; + content: ""; + vertical-align: -0.12em; + animation: mjj-jrpg-cursor + calc(var(--zenbu-sys-motion-duration-layout) * 2) + steps(1, end) + infinite; +} + +@keyframes mjj-jrpg-cursor { + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .jrpg-message[data-streaming="true"] p::after { + animation: none; + } } -.jrpg-next { - display: flex; +.jrpg-turn-navigation { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - justify-content: end; gap: var(--zenbu-sys-space-icon-label); - margin: 0; + padding-top: var(--zenbu-sys-padding-sm); + border-top: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); +} + +.jrpg-turn-navigation zen-button, +.jrpg-turn-navigation button { + width: 100%; +} + +.jrpg-turn-navigation button { + justify-content: center; + border: var(--zenbu-sys-stroke-width) solid transparent; + border-radius: 0; + background: transparent; color: var(--mjj-jrpg-primary); font-size: var(--zenbu-sys-font-size-xs); + cursor: pointer; +} + +.jrpg-turn-navigation button:hover:not(:disabled) { + border-color: var(--mjj-jrpg-primary); + background: var(--mjj-jrpg-surface-raised); +} + +.jrpg-turn-navigation button:disabled { + color: var(--mjj-jrpg-text-muted); + cursor: not-allowed; + opacity: var(--zenbu-sys-opacity-disabled); +} + +.jrpg-turn-navigation [data-turn-position] { + justify-self: center; + color: var(--mjj-jrpg-text-muted); + font-size: var(--zenbu-sys-font-size-xs); + letter-spacing: 0.08em; } .jrpg-utility { @@ -299,7 +365,7 @@ .jrpg-preview-panel { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto auto; + grid-template-rows: auto auto auto minmax(0, 1fr) auto auto; gap: var(--zenbu-sys-space-group); height: 100%; padding: var(--zenbu-sys-padding-lg); @@ -327,6 +393,40 @@ color: var(--mjj-jrpg-text); } +.jrpg-work-showcase { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: var(--zenbu-sys-space-control); + min-height: 0; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.jrpg-work-showcase a { + display: grid; + gap: var(--zenbu-sys-space-icon-label); + height: 100%; + padding: var(--zenbu-sys-padding-sm); + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); + background: var(--mjj-jrpg-panel); + color: var(--mjj-jrpg-primary); + text-decoration: none; +} + +.jrpg-work-showcase a:hover, +.jrpg-work-showcase a:focus-visible { + border-color: var(--mjj-jrpg-accent); + background: var(--mjj-jrpg-surface-raised); +} + +.jrpg-work-showcase small { + color: var(--mjj-jrpg-text-muted); + font-size: var(--zenbu-sys-font-size-xs); +} + .jrpg-preview-stats { display: grid; grid-template-columns: repeat(2, 1fr); @@ -434,9 +534,133 @@ margin-bottom: var(--zenbu-sys-space-content); } +.jrpg-preview-dialog dialog.jrpg-resume-dialog { + width: min(94vw, 72rem); + max-height: calc(100dvh - var(--zenbu-sys-space-container)); + overflow: hidden; +} + +.jrpg-preview-dialog dialog.jrpg-resume-dialog[open] { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: var(--zenbu-sys-space-group); +} + +.jrpg-resume-dossier { + min-height: 0; + padding: var(--zenbu-sys-padding-lg); + overflow: auto; + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); + background: var(--mjj-jrpg-surface-sunken); + scrollbar-color: var(--mjj-jrpg-frame) transparent; +} + +.jrpg-resume-dossier[hidden], +[data-dialog-copy][hidden], +[data-resume-status][hidden], +[data-resume-download][hidden] { + display: none; +} + +.jrpg-resume-dossier [data-resume-status] { + margin: 0; + color: var(--mjj-jrpg-highlight); +} + +.jrpg-resume-dossier [data-resume-content] { + display: grid; + gap: var(--zenbu-sys-space-group); + color: var(--mjj-jrpg-text); +} + +.jrpg-resume-dossier :where(p, ul) { + margin-block: 0; +} + +.jrpg-resume-dossier .info { + display: grid; + gap: var(--zenbu-sys-space-control); + padding: var(--zenbu-sys-padding-lg); + border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-primary); + background: var(--mjj-jrpg-panel); +} + +.jrpg-resume-dossier .info > p { + color: var(--mjj-jrpg-primary); + font-size: var(--zenbu-sys-font-size-xl); +} + +.jrpg-resume-dossier .header-address-style, +.jrpg-resume-dossier .header-social-style { + display: flex; + flex-wrap: wrap; + gap: var(--zenbu-sys-space-control); + color: var(--mjj-jrpg-text-muted); +} + +.jrpg-resume-dossier .sub-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--zenbu-sys-space-control); + margin-top: var(--zenbu-sys-space-content); +} + +.jrpg-resume-dossier .sub-header h2 { + margin: 0; + color: var(--mjj-jrpg-highlight); + font-size: var(--zenbu-sys-font-size-lg); + text-transform: uppercase; +} + +.jrpg-resume-dossier .sub-header .line { + height: var(--zenbu-sys-stroke-width-emphasis); + background: var(--mjj-jrpg-accent); +} + +.jrpg-resume-dossier .flex-box { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--zenbu-sys-space-control); + padding-top: var(--zenbu-sys-padding-sm); + border-top: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); +} + +.jrpg-resume-dossier .entry-title-style, +.jrpg-resume-dossier .entry-position-style { + color: var(--mjj-jrpg-primary); +} + +.jrpg-resume-dossier .entry-location-style, +.jrpg-resume-dossier .entry-date-style, +.jrpg-resume-dossier .skill-type-style { + color: var(--mjj-jrpg-text-muted); + text-align: right; +} + +.jrpg-resume-dossier .description-style { + display: grid; + gap: var(--zenbu-sys-space-control); + padding-left: var(--zenbu-sys-padding-xl); +} + +.jrpg-resume-dossier a { + color: var(--mjj-jrpg-primary); + text-decoration-color: var(--mjj-jrpg-accent); + text-underline-offset: var(--zenbu-sys-space-icon-label); +} + +.jrpg-dialog-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--zenbu-sys-space-control); +} + mjj-jrpg-composer { grid-area: composer; display: block; + min-height: 0; padding: var(--zenbu-sys-padding-md); background: var(--mjj-jrpg-surface); } @@ -444,26 +668,42 @@ .jrpg-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr) auto; gap: var(--zenbu-sys-space-control); + height: 100%; + min-height: 0; } -.jrpg-composer zen-field { +.jrpg-composer zen-field[appearance="plain"] { display: grid; + grid-column: 1; + grid-row: 1; + grid-template-rows: auto minmax(0, 1fr); gap: var(--zenbu-sys-space-icon-label); + min-height: 0; } .jrpg-composer label, .jrpg-composer-hint { grid-column: 1 / -1; + grid-row: 2; color: var(--mjj-jrpg-text-muted); font-size: var(--zenbu-sys-font-size-xs); } +.jrpg-composer zen-field label { + grid-column: 1; + grid-row: 1; +} + .jrpg-composer textarea { box-sizing: border-box; + grid-column: 1; + grid-row: 2; width: 100%; + height: 100%; min-height: calc(var(--zenbu-control-height) * 2); - resize: vertical; + resize: none; padding: var(--zenbu-control-padding-block) var(--zenbu-control-padding-inline); @@ -483,11 +723,25 @@ outline-offset: var(--zenbu-sys-focus-offset); } +.jrpg-composer-actions { + display: flex; + grid-column: 2; + grid-row: 1; + flex-direction: column; + justify-content: flex-end; + gap: var(--zenbu-sys-space-control); +} + .jrpg-composer zen-button { - align-self: end; + width: 100%; +} + +.jrpg-composer zen-button[hidden] { + display: none; } .jrpg-composer zen-button > button { + width: 100%; border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); border-radius: 0; background: var(--mjj-jrpg-primary); @@ -655,10 +909,39 @@ .jrpg-composer { grid-template-columns: 1fr; + grid-template-rows: + minmax(calc(var(--zenbu-control-height) * 3), 1fr) + auto + auto; + } + + .jrpg-composer-actions { + grid-column: 1; + grid-row: 2; + flex-direction: row; + } + + .jrpg-composer-hint { + grid-row: 3; } - .jrpg-composer zen-button { - width: 100%; + .jrpg-composer-actions zen-button { + flex: 1; + } + + .jrpg-work-showcase, + .jrpg-dialog-actions { + grid-template-columns: 1fr; + } + + .jrpg-resume-dossier .flex-box { + display: grid; + } + + .jrpg-resume-dossier .entry-location-style, + .jrpg-resume-dossier .entry-date-style, + .jrpg-resume-dossier .skill-type-style { + text-align: left; } .jrpg-composer zen-button > button {
--- a/mrjunejune/src/jrpg/jrpg.js Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Wed Aug 05 20:38:32 2026 -0700 @@ -20,6 +20,38 @@ title: "Resume", type: "Profile", url: "/resume", + works: [ + { + detail: "Agentic execution", + label: "Copilot Tasks", + url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", + }, + { + detail: "AI platform", + label: "Copilot SuperApp", + url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", + }, + { + detail: "Build 2026", + label: "Code", + url: "https://news.microsoft.com/build-2026/", + }, + { + detail: "Personal agent", + label: "Autopilot", + url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", + }, + { + detail: "Ads systems", + label: "Meta", + url: "https://www.meta.com/", + }, + { + detail: "Workspace platform", + label: "Google", + url: "https://www.google.com/", + }, + ], }, tools: { copy: "Small, focused utilities for writing, media, and experimentation.", @@ -27,6 +59,11 @@ title: "Tools", type: "Utilities", url: "/tools", + works: [ + { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, + { detail: "Media", label: "Converter", url: "/tools/file_converter" }, + { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, + ], }, blog: { copy: "Notes from building systems, games, web tools, and curious prototypes.", @@ -34,10 +71,102 @@ title: "Blogs", type: "Writing", url: "/blog", + works: [ + { detail: "Archive", label: "All posts", url: "/blog" }, + ], }, }); const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; +const TYPING_INTERVAL_MS = 18; + +class StreamingTextAnimator { + constructor(render) { + this._render = render; + this._displayed = ""; + this._target = ""; + this._timer = 0; + this._waiters = []; + this._reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + } + + append(text) { + if (typeof text !== "string" || !text) return; + this._target += text; + this._schedule(); + } + + complete(text) { + if (typeof text === "string") { + this._target = text; + if (!this._target.startsWith(this._displayed)) { + let shared = 0; + while ( + shared < this._target.length && + shared < this._displayed.length && + this._target[shared] === this._displayed[shared] + ) { + shared++; + } + this._displayed = this._displayed.slice(0, shared); + this._render(this._displayed); + } + } + this._schedule(); + } + + waitForIdle() { + if (!this._timer && this._displayed === this._target) { + return Promise.resolve(); + } + return new Promise(resolve => { + this._waiters.push(resolve); + }); + } + + cancel() { + window.clearTimeout(this._timer); + this._timer = 0; + this._target = this._displayed; + this._settle(); + } + + _schedule() { + if (this._reduceMotion) { + this._displayed = this._target; + this._render(this._displayed); + this._settle(); + return; + } + if (this._timer || this._displayed === this._target) return; + this._timer = window.setTimeout(() => this._tick(), TYPING_INTERVAL_MS); + } + + _tick() { + this._timer = 0; + const remaining = this._target.length - this._displayed.length; + if (remaining <= 0) { + this._settle(); + return; + } + const amount = Math.max(1, Math.ceil(remaining / 80)); + this._displayed = this._target.slice( + 0, + this._displayed.length + amount, + ); + this._render(this._displayed); + if (this._displayed === this._target) this._settle(); + else this._schedule(); + } + + _settle() { + if (this._timer || this._displayed !== this._target) return; + const waiters = this._waiters.splice(0); + for (const resolve of waiters) resolve(); + } +} class MjjJrpgCharacter extends HTMLElement { static get observedAttributes() { @@ -66,33 +195,89 @@ connectedCallback() { this._messages = this.querySelector("[data-messages]"); this._viewport = this.querySelector("[data-zen-viewport]"); + this._previousButton = this.querySelector("[data-turn-previous]"); + this._nextButton = this.querySelector("[data-turn-next]"); + this._position = this.querySelector("[data-turn-position]"); + this._initialMessages = [...(this._messages?.children || [])].map( + item => item.cloneNode(true), + ); + this._turnSequence = 0; + this._activeGroup = null; + this._onNavigationClick = event => { + const button = event.target.closest("button"); + if (!button || !this.contains(button) || button.disabled) return; + if (button === this._previousButton) this._moveTurn(-1); + else if (button === this._nextButton) this._moveTurn(1); + }; + this.addEventListener("click", this._onNavigationClick); + this._refreshTurnGroups("start"); + } + + disconnectedCallback() { + this.removeEventListener("click", this._onNavigationClick); } appendMessage(speaker, text) { + let group = this._activeGroup || "start"; + if (speaker === "June") { + this._turnSequence++; + group = `turn-${this._turnSequence}`; + this._activeGroup = group; + } + return this._createMessage(speaker, text, group, true); + } + + _createMessage(speaker, text, group, select) { if (!this._messages) return; const item = document.createElement("li"); const name = document.createElement("strong"); const copy = document.createElement("p"); item.className = "jrpg-message"; item.dataset.speaker = speaker; + item.dataset.turnGroup = group; name.textContent = speaker; copy.textContent = text; item.append(name, copy); this._messages.append(item); - this.scrollEnd(); + this._refreshTurnGroups(select ? group : this._currentGroup); return item; } replaceMessages(turns) { if (!this._messages) return; this._messages.replaceChildren(); - for (const turn of turns) { - if (!["user", "assistant"].includes(turn.role)) continue; - this.appendMessage( + this._turnSequence = 0; + this._activeGroup = null; + const visibleTurns = turns.filter(turn => + ["user", "assistant"].includes(turn.role) + ); + if (!visibleTurns.length) { + this._messages.append( + ...this._initialMessages.map(item => item.cloneNode(true)), + ); + this._refreshTurnGroups("start"); + return; + } + let group = null; + for (const turn of visibleTurns) { + if (turn.role === "user" || !group) { + this._turnSequence++; + group = `turn-${this._turnSequence}`; + this._activeGroup = group; + } + this._createMessage( turn.role === "user" ? "June" : "Epi", - turn.content || (turn.status === "failed" ? "The quest failed." : ""), + turn.content || + (turn.status === "aborted" + ? "Quest cancelled." + : turn.status === "failed" + ? "The quest failed." + : ""), + group, + false, ); } + this._refreshTurnGroups(group); } startAssistantMessage() { @@ -105,6 +290,64 @@ this.scrollEnd(); } + setMessageStreaming(item, streaming) { + if (!item) return; + if (streaming) item.dataset.streaming = "true"; + else delete item.dataset.streaming; + this._viewport?.setAttribute("aria-busy", String(streaming)); + } + + _refreshTurnGroups(preferredGroup) { + const groups = []; + for (const item of this._messages?.children || []) { + const group = item.dataset.turnGroup || "start"; + item.dataset.turnGroup = group; + if (!groups.includes(group)) groups.push(group); + } + this._turnGroups = groups; + const preferredIndex = groups.indexOf(preferredGroup); + const currentIndex = groups.indexOf(this._currentGroup); + this._turnIndex = preferredIndex >= 0 + ? preferredIndex + : currentIndex >= 0 + ? currentIndex + : Math.max(0, groups.length - 1); + this._showTurn(); + } + + _moveTurn(offset) { + const nextIndex = Math.max( + 0, + Math.min(this._turnGroups.length - 1, this._turnIndex + offset), + ); + if (nextIndex === this._turnIndex) return; + this._turnIndex = nextIndex; + this._showTurn(); + } + + _showTurn() { + const group = this._turnGroups?.[this._turnIndex]; + this._currentGroup = group; + for (const item of this._messages?.children || []) { + item.hidden = item.dataset.turnGroup !== group; + } + if (this._previousButton) this._previousButton.disabled = this._turnIndex <= 0; + if (this._nextButton) { + this._nextButton.disabled = + this._turnIndex >= (this._turnGroups?.length || 0) - 1; + } + if (this._position) { + if (group === "start") { + this._position.textContent = "START"; + } else { + const turns = this._turnGroups.filter(item => item !== "start"); + this._position.textContent = + `TURN ${turns.indexOf(group) + 1} / ${turns.length}`; + } + } + this.scrollEnd(); + } + scrollEnd() { requestAnimationFrame(() => { if (this._viewport) { @@ -161,6 +404,10 @@ if (this._cancelControl) this._cancelControl.hidden = !busy; } + setCancellable(cancellable) { + if (this._cancelControl) this._cancelControl.hidden = !cancellable; + } + setDisabled(disabled) { if (this._textarea) this._textarea.disabled = disabled; if (this._button) this._button.disabled = disabled; @@ -190,9 +437,22 @@ class MjjJrpgPreview extends HTMLElement { connectedCallback() { + this._onClick = event => { + if ( + event.target.closest("[data-zen-trigger]") && + this.dataset.selection === "resume" + ) { + void this.loadResume(); + } + }; + this.addEventListener("click", this._onClick); this.show(this.dataset.selection || "resume"); } + disconnectedCallback() { + this.removeEventListener("click", this._onClick); + } + show(selection) { const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; @@ -202,9 +462,79 @@ this.querySelector("[data-preview-type]").textContent = preview.type; this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; + const showcase = this.querySelector("[data-work-showcase]"); + showcase.replaceChildren(...preview.works.map(work => { + const item = document.createElement("li"); + const link = document.createElement("a"); + const label = document.createElement("span"); + const detail = document.createElement("small"); + link.href = work.url; + if (new URL(work.url, window.location.href).origin !== window.location.origin) { + link.target = "_blank"; + link.rel = "noreferrer"; + } + label.textContent = work.label; + detail.textContent = work.detail; + link.append(label, detail); + item.append(link); + return item; + })); const link = this.querySelector("[data-preview-link]"); link.href = preview.url; link.setAttribute("aria-label", `Open ${preview.title}`); + const resumeDossier = this.querySelector("[data-resume-dossier]"); + const resumeDownload = this.querySelector("[data-resume-download]"); + const isResume = selection === "resume"; + resumeDossier.hidden = !isResume; + resumeDownload.hidden = !isResume; + this.querySelector("[data-dialog-copy]").hidden = isResume; + } + + async loadResume() { + if (this._resumeLoaded || this._resumeLoading) return; + this._resumeLoading = true; + const status = this.querySelector("[data-resume-status]"); + const content = this.querySelector("[data-resume-content]"); + try { + const response = await fetch("/resume", { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Resume request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + const resume = documentCopy.querySelector("main"); + if (!resume) throw new Error("Resume content is unavailable"); + for (const unsafe of resume.querySelectorAll( + "embed, iframe, object, script, style, svg", + )) { + unsafe.remove(); + } + for (const element of resume.querySelectorAll("*")) { + for (const attribute of [...element.attributes]) { + if (attribute.name.toLowerCase().startsWith("on")) { + element.removeAttribute(attribute.name); + } + } + } + for (const anchor of resume.querySelectorAll("a")) { + const target = new URL(anchor.href, window.location.href); + if (target.origin !== window.location.origin) { + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + } + } + content.replaceChildren(...resume.childNodes); + status.hidden = true; + this._resumeLoaded = true; + } catch (error) { + status.textContent = `Unable to load resume: ${error.message}`; + } finally { + this._resumeLoading = false; + } } } @@ -327,9 +657,15 @@ character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); const assistantItem = chat?.startAssistantMessage(); - let assistantText = ""; + chat?.setMessageStreaming(assistantItem, true); + const assistantText = new StreamingTextAnimator(text => { + chat?.setMessageText(assistantItem, text); + }); let turnFinished = false; activeController = new AbortController(); + activeController.signal.addEventListener("abort", () => { + assistantText.cancel(); + }, { once: true }); try { const createConversation = async () => { @@ -370,23 +706,42 @@ throw new Error(message); } await consumeSse(response, (eventName, data) => { + shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { + bubbles: true, + detail: { + data, + type: eventName, + }, + })); if (eventName === "assistant.delta") { - assistantText += data.delta || ""; - chat?.setMessageText(assistantItem, assistantText); + assistantText.append(data.delta || ""); } else if (eventName === "assistant.completed") { - assistantText = data.content || assistantText; - chat?.setMessageText(assistantItem, assistantText); + assistantText.complete(data.content); } else if (eventName === "turn.error") { - throw new Error(data.message || "Inference turn failed"); + throw new Error( + data.error?.message || data.message || "Inference turn failed", + ); } else if (eventName === "turn.done") { turnFinished = true; - if (data.failed) throw new Error("Inference turn failed"); + composer?.setCancellable(false); + if (data.failed || data.aborted) { + throw new Error( + data.aborted ? "Inference turn aborted" : "Inference turn failed", + ); + } } }); if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); + await assistantText.waitForIdle(); + if (activeController.signal.aborted) { + throw new DOMException("Quest cancelled", "AbortError"); + } + chat?.setMessageStreaming(assistantItem, false); character?.setAttribute("state", "default"); } catch (error) { const aborted = error instanceof DOMException && error.name === "AbortError"; + assistantText.cancel(); + chat?.setMessageStreaming(assistantItem, false); chat?.setMessageText( assistantItem, aborted ? "Quest cancelled." : `System error: ${error.message}`,
--- a/mrjunejune/src/sw.js Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/src/sw.js Wed Aug 05 20:38:32 2026 -0700 @@ -1,5 +1,5 @@ // Root-scoped Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v10-inference-chat'; +const CACHE_VERSION = 'v13-turn-resume'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; // Files to cache immediately on install
--- a/mrjunejune/test/conversation_api_test.js Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/test/conversation_api_test.js Wed Aug 05 20:38:32 2026 -0700 @@ -149,6 +149,8 @@ ); const stream = await response.text(); assert.match(stream, /event: turn\.accepted/); + assert.match(stream, /event: tool\.started/); + assert.match(stream, /"tool":\{"name":"mock-search"\}/); assert.match(stream, /event: assistant\.delta/); assert.match(stream, /hello \\"traveler\\"\\\\path/); assert.match(stream, /event: assistant\.completed/);
--- a/mrjunejune/test/inference_bridge_fake_sidecar.c Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/test/inference_bridge_fake_sidecar.c Wed Aug 05 20:38:32 2026 -0700 @@ -51,6 +51,12 @@ request_id, conversation_id); printf( + "{\"type\":\"tool.started\",\"request_id\":\"%s\"," + "\"conversation_id\":\"%s\"," + "\"tool\":{\"name\":\"mock-search\"}}\n", + request_id, + conversation_id); + printf( "{\"type\":\"assistant.delta\",\"request_id\":\"%s\"," "\"conversation_id\":\"%s\"," "\"delta\":\"hello \\\"traveler\\\"\\\\path\"}\n",
--- a/mrjunejune/test/inference_bridge_test.c Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/test/inference_bridge_test.c Wed Aug 05 20:38:32 2026 -0700 @@ -13,9 +13,11 @@ int deltas; int completed; int usage; + int custom; int done; int closed; char delta[128]; + char custom_json[256]; int64 input_tokens; int64 output_tokens; } Test_State; @@ -41,6 +43,15 @@ p_state->input_tokens = p_event->input_tokens; p_state->output_tokens = p_event->output_tokens; } + else if (strcmp(p_event->type, "tool.started") == 0) + { + p_state->custom++; + snprintf( + p_state->custom_json, + sizeof(p_state->custom_json), + "%s", + p_event->raw_json); + } else if (strcmp(p_event->type, "turn.done") == 0 && strncmp(p_event->request_id, "request-", 8) == 0) { @@ -83,7 +94,9 @@ assert(state.deltas == 1); assert(state.completed == 1); assert(state.usage == 1); + assert(state.custom == 1); assert(strcmp(state.delta, "hello \"traveler\"\\path") == 0); + assert(strstr(state.custom_json, "\"name\":\"mock-search\"")); assert(state.input_tokens == 7); assert(state.output_tokens == 3);
--- a/mrjunejune/test/production_inference_bundle_test.sh Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/test/production_inference_bundle_test.sh Wed Aug 05 20:38:32 2026 -0700 @@ -26,6 +26,11 @@ fi done +if grep -Eq '/mock_sidecar($|\\.)|/mock_responses\\.json$' <<<"$files"; then + echo "Production bundle contains the development mock inference runtime" >&2 + exit 1 +fi + config="$(find -L "$bundle" -type f -name litellm_config.yaml -print -quit)" grep -q 'os.environ/LITELLM_MASTER_KEY' "$config" if grep -Eq 'sk-[A-Za-z0-9_-]{16,}|gh[opurs]_[A-Za-z0-9]{16,}' "$config"; then
--- a/mrjunejune/test/snapshots/jrpg.snapshot Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/test/snapshots/jrpg.snapshot Wed Aug 05 20:38:32 2026 -0700 @@ -47,7 +47,6 @@ <mjj-jrpg-chat aria-labelledby="conversation-title"> <div class="jrpg-panel-heading"> - <span aria-hidden="true">QUEST 01</span> <h1 id="conversation-title">Conversations</h1> </div> <zen-message-scroller> @@ -56,23 +55,35 @@ data-zen-viewport role="log" aria-live="polite" - aria-relevant="additions" + aria-relevant="additions text" > <ol class="jrpg-messages" data-messages> - <li class="jrpg-message" data-speaker="Epi"> + <li class="jrpg-message" data-speaker="Epi" data-turn-group="start"> <strong>Epi</strong> <p>Welcome, traveler. Pick a destination or tell me what you are looking for.</p> </li> - <li class="jrpg-message" data-speaker="System"> + <li class="jrpg-message" data-speaker="System" data-turn-group="start"> <strong>System</strong> <p>The Shiba terminal is ready.</p> </li> </ol> </div> </zen-message-scroller> - <p class="jrpg-next" aria-hidden="true"> - NEXT <zen-icon name="chevron-right" size="0.9em"></zen-icon> - </p> + <nav class="jrpg-turn-navigation" aria-label="Conversation turns"> + <zen-button appearance="plain" size="sm"> + <button type="button" data-turn-previous aria-label="Previous turn" disabled> + <zen-icon name="chevron-left"></zen-icon> + Previous + </button> + </zen-button> + <span data-turn-position aria-live="polite">START</span> + <zen-button appearance="plain" size="sm"> + <button type="button" data-turn-next aria-label="Next turn" disabled> + Next + <zen-icon name="chevron-right"></zen-icon> + </button> + </zen-button> + </nav> </mjj-jrpg-chat> </section> @@ -93,6 +104,20 @@ <h2 data-preview-title>Resume</h2> </zen-heading> <p data-preview-copy>Experience, projects, and the systems I have helped build.</p> + <ul class="jrpg-work-showcase" data-work-showcase> + <li> + <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/"> + <span>Copilot Tasks</span> + <small>Agentic execution</small> + </a> + </li> + <li> + <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html"> + <span>Copilot SuperApp</span> + <small>AI platform</small> + </a> + </li> + </ul> <dl class="jrpg-preview-stats"> <div> <dt>TYPE</dt> @@ -111,7 +136,7 @@ <zen-icon name="search"></zen-icon> </button> </zen-button> - <dialog aria-labelledby="jrpg-dialog-title"> + <dialog class="jrpg-resume-dialog" aria-labelledby="jrpg-dialog-title"> <div class="jrpg-dialog-heading"> <p>DESTINATION DATA</p> <zen-button appearance="plain" size="md"> @@ -122,12 +147,28 @@ </div> <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2> <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p> - <zen-button appearance="plain" size="lg"> - <a href="/resume" data-preview-link> - Open destination - <zen-icon name="arrow-right"></zen-icon> - </a> - </zen-button> + <section + class="jrpg-resume-dossier" + data-resume-dossier + aria-label="Resume dossier" + > + <p data-resume-status>Loading resume dossier...</p> + <div data-resume-content></div> + </section> + <div class="jrpg-dialog-actions"> + <zen-button appearance="plain" size="lg"> + <a href="/resume" data-preview-link> + Open full page + <zen-icon name="arrow-right"></zen-icon> + </a> + </zen-button> + <zen-button appearance="plain" size="lg" data-resume-download> + <a href="/public/resume.pdf"> + Download PDF + <zen-icon name="download"></zen-icon> + </a> + </zen-button> + </div> </dialog> </zen-dialog> </article> @@ -147,18 +188,20 @@ required ></textarea> </zen-field> - <zen-button appearance="plain" size="lg"> - <button type="submit"> - Send - <zen-icon name="arrow-right"></zen-icon> - </button> - </zen-button> - <zen-button appearance="plain" size="lg" data-cancel-control hidden> - <button type="button" data-cancel> - Cancel - <zen-icon name="close"></zen-icon> - </button> - </zen-button> + <div class="jrpg-composer-actions"> + <zen-button appearance="plain" size="lg"> + <button type="submit"> + Send + <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="lg" data-cancel-control hidden> + <button type="button" data-cancel> + Cancel + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </div> <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p> </form> </mjj-jrpg-composer>
--- a/mrjunejune/test/theme_and_webp_test.js Wed Aug 05 09:19:41 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Wed Aug 05 20:38:32 2026 -0700 @@ -476,8 +476,21 @@ const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); const composer = document.querySelector('mjj-jrpg-composer') .getBoundingClientRect(); + const composerField = document.querySelector('.jrpg-composer zen-field') + .getBoundingClientRect(); + const composerFieldStyle = getComputedStyle( + document.querySelector('.jrpg-composer zen-field'), + ); + const composerLabel = document.querySelector('.jrpg-composer label') + .getBoundingClientRect(); + const composerTextarea = document.querySelector('#jrpg-message') + .getBoundingClientRect(); const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); const character = document.querySelector('[data-character]'); + const messageScroller = document.querySelector('zen-message-scroller') + .getBoundingClientRect(); + const messageViewport = document.querySelector('.jrpg-message-viewport') + .getBoundingClientRect(); const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); const main = document.querySelector('.jrpg-main').getBoundingClientRect(); const colorProbe = document.createElement('span'); @@ -558,6 +571,27 @@ characterLoaded: character.complete && character.naturalWidth > 0, colors, composerBelowScene: composer.top >= scene.bottom, + composerTextareaFills: ( + Math.abs(composerTextarea.left - composerField.left) < 1 && + Math.abs(composerTextarea.right - composerField.right) < 1 && + Math.abs(composerTextarea.bottom - composerField.bottom) < 1 && + composerTextarea.height > 60 + ), + composerTextareaGeometry: { + fieldBottom: composerField.bottom, + fieldDisplay: composerFieldStyle.display, + fieldGridRows: composerFieldStyle.gridTemplateRows, + fieldLeft: composerField.left, + fieldRight: composerField.right, + fieldTop: composerField.top, + labelBottom: composerLabel.bottom, + labelTop: composerLabel.top, + textareaBottom: composerTextarea.bottom, + textareaHeight: composerTextarea.height, + textareaLeft: composerTextarea.left, + textareaRight: composerTextarea.right, + textareaTop: composerTextarea.top, + }, fillsViewport: shell.left === 0 && shell.top === 0 && Math.abs(shell.right - innerWidth) < 1 && @@ -572,8 +606,14 @@ font.status === 'loaded' ), menuRightOfComposer: menu.left >= composer.right, + messageViewportFills: ( + Math.abs(messageViewport.top - messageScroller.top) < 1 && + Math.abs(messageViewport.bottom - messageScroller.bottom) < 1 && + messageViewport.height > 192 + ), noOverflow: document.documentElement.scrollWidth <= innerWidth, nonPixelText, + questLabelAbsent: !document.body.textContent.includes('QUEST 01'), theme: document.documentElement.dataset.zenTheme, utilityRightOfScene: utility.left >= scene.right, }; @@ -592,12 +632,19 @@ assert.equal(desktop.colors.submit, desktop.colors.primary); assert.equal(desktop.colors.utility, desktop.colors.surface); assert.equal(desktop.composerBelowScene, true); + assert.equal( + desktop.composerTextareaFills, + true, + JSON.stringify(desktop.composerTextareaGeometry), + ); assert.equal(desktop.fillsViewport, true); assert.ok(desktop.fonts.every(font => font === '"Pixel Mplus"')); assert.equal(desktop.fontLoaded, true); assert.equal(desktop.menuRightOfComposer, true); + assert.equal(desktop.messageViewportFills, true); assert.equal(desktop.noOverflow, true); assert.deepEqual(desktop.nonPixelText, []); + assert.equal(desktop.questLabelAbsent, true); assert.equal(desktop.theme, 'cyberpunk'); assert.equal(desktop.utilityRightOfScene, true); await page.locator('.jrpg-brand a').focus(); @@ -618,6 +665,27 @@ await page.locator('meta[name="theme-color"]').first().getAttribute('content'), desktop.colors.canvas, ); + assert.equal(await page.locator('[data-work-showcase] a').count(), 6); + assert.match( + await page.locator('[data-work-showcase]').textContent(), + /Copilot Tasks/, + ); + assert.match( + await page.locator('[data-work-showcase]').textContent(), + /Copilot SuperApp/, + ); + assert.equal( + await page.locator('[data-turn-position]').textContent(), + 'START', + ); + assert.equal( + await page.getByRole('button', { name: 'Previous turn' }).isDisabled(), + true, + ); + assert.equal( + await page.getByRole('button', { name: 'Next turn' }).isDisabled(), + true, + ); await page.getByRole('button', { name: 'Tools', exact: true }).click(); assert.equal( @@ -633,6 +701,7 @@ .getAttribute('aria-pressed'), 'true', ); + assert.equal(await page.locator('[data-work-showcase] a').count(), 3); await page.getByRole('button', { name: /Inspect/ }).click(); await page.locator('.jrpg-preview-dialog dialog[open]').waitFor(); @@ -643,10 +712,49 @@ await page.getByRole('button', { name: 'Close destination details', }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page.getByRole('button', { name: /Inspect/ }).click(); + await page.locator('[data-resume-content] .sub-header').first().waitFor(); + const resumeDossier = await page.locator('[data-resume-dossier]').evaluate( + dossier => ({ + fontFamily: getComputedStyle(dossier).fontFamily, + text: dossier.textContent, + }), + ); + assert.equal(resumeDossier.fontFamily, '"Pixel Mplus"'); + assert.match(resumeDossier.text, /Copilot Tasks/); + assert.match(resumeDossier.text, /Microsoft/); + assert.match(resumeDossier.text, /Meta/); + assert.equal( + await page.locator('[data-resume-content] a[target="_blank"]').count() > 0, + true, + ); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + + await page.evaluate(() => { + window.__jrpgStreamEvents = []; + document.querySelector('mjj-jrpg-shell').addEventListener( + 'mjj-jrpg-stream-event', + event => window.__jrpgStreamEvents.push(event.detail), + ); + }); await page.locator('#jrpg-message').fill('Where should I begin?'); await page.locator('#jrpg-message').press('Enter'); await page.locator('.jrpg-message[data-speaker="June"]').last().waitFor(); + const streamingMessage = page.locator( + '.jrpg-message[data-speaker="Epi"][data-streaming="true"]', + ).last(); + await streamingMessage.waitFor(); + await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' }); + await streamingMessage.waitFor(); + assert.notEqual( + await streamingMessage.locator('p').textContent(), + 'hello traveler', + ); await page.getByRole('button', { name: 'Blogs', exact: true }).click(); assert.equal( await page.locator('.jrpg-message[data-speaker="June"] p').last() @@ -658,6 +766,66 @@ 'default' && document.querySelectorAll('.jrpg-message[data-speaker="Epi"]').length > 1 ); + assert.equal( + await page.locator('.jrpg-message[data-speaker="Epi"] p').last() + .textContent(), + 'hello traveler', + ); + assert.equal( + await page.locator( + '.jrpg-message[data-speaker="Epi"][data-streaming="true"]', + ).count(), + 0, + ); + assert.deepEqual( + await page.evaluate(() => { + const event = window.__jrpgStreamEvents.find( + item => item.type === 'tool.started', + ); + return { + name: event?.data.tool?.name, + type: event?.type, + }; + }), + { + name: 'mock-search', + type: 'tool.started', + }, + ); + assert.equal( + await page.locator('[data-turn-position]').textContent(), + 'TURN 1 / 1', + ); + assert.equal( + await page.getByRole('button', { name: 'Previous turn' }).isEnabled(), + true, + ); + assert.equal( + await page.getByRole('button', { name: 'Next turn' }).isDisabled(), + true, + ); + await page.getByRole('button', { name: 'Previous turn' }).click(); + assert.equal( + await page.locator('[data-turn-position]').textContent(), + 'START', + ); + assert.equal( + await page.locator('.jrpg-message[data-speaker="June"]:visible').count(), + 0, + ); + assert.equal( + await page.locator('.jrpg-message[data-speaker="System"]:visible').count(), + 1, + ); + await page.getByRole('button', { name: 'Next turn' }).click(); + assert.equal( + await page.locator('[data-turn-position]').textContent(), + 'TURN 1 / 1', + ); + assert.equal( + await page.locator('.jrpg-message[data-speaker="June"]:visible').count(), + 1, + ); assert.equal(await page.locator('#jrpg-message').isEnabled(), true); await page.waitForFunction(() => navigator.serviceWorker?.controller); @@ -1041,7 +1209,7 @@ for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } - assert.match(serviceWorker, /v10-inference-chat/); + assert.match(serviceWorker, /v13-turn-resume/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`)