From 26f1de976a19c5f3b7c60d16c952eca044c3277f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 29 Sep 2026 05:48:05 +0900 Subject: [PATCH] feat: model Worker Console display states --- .../console/ConsoleDisplayState.svelte | 150 ++++++++ .../console/console-display-state.ts | 37 ++ .../console/worker-console.ui.test.ts | 50 +++ .../workers/[workerRef]/console/+page.svelte | 188 +++++++++- .../console/console-display.browser.spec.ts | 349 ++++++++++++++++++ 5 files changed, 762 insertions(+), 12 deletions(-) create mode 100644 web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte create mode 100644 web/workspace/src/lib/workspace/console/console-display-state.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte b/web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte new file mode 100644 index 000000000..1eb3c9591 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ConsoleDisplayState.svelte @@ -0,0 +1,150 @@ + + +{#if state.kind === "loading"} +
+ + + Loading conversation + {#if state.stage === "session"} + Checking Worker Session availability… + {:else} + Waiting for the initial conversation snapshot… + {/if} + +
+{:else if state.kind === "unavailable" || state.kind === "failed"} + +{:else} + {#if state.kind === "stale"} +
+ + + {state.phase === "reconnecting" + ? "Refreshing conversation" + : "Conversation updates are unavailable"} + + {state.reason} Previously loaded conversation is preserved. + + +
+ {:else if state.source === "retained"} +
+ + Read-only retained conversation + This saved Session snapshot cannot receive live updates. + +
+ {/if} + + {#if state.kind === "ready" && !hasContent} +
+ + No conversation to display + + {hasUnfilteredContent + ? "No console items match the current display mode." + : "This Worker view has no conversation history yet."} + + +
+ {/if} +{/if} + + diff --git a/web/workspace/src/lib/workspace/console/console-display-state.ts b/web/workspace/src/lib/workspace/console/console-display-state.ts new file mode 100644 index 000000000..c20d14836 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/console-display-state.ts @@ -0,0 +1,37 @@ +export type ConsoleDisplaySource = "live" | "retained"; + +export type ConsoleDisplayState = + | { kind: "loading"; stage: "session" | "snapshot" } + | { kind: "ready"; source: ConsoleDisplaySource } + | { kind: "unavailable"; reason: string } + | { kind: "failed"; reason: string } + | { + kind: "stale"; + source: ConsoleDisplaySource; + phase: "reconnecting" | "disconnected"; + reason: string; + }; + +const MAX_CONSOLE_REASON_LENGTH = 240; + +export function boundedConsoleReason( + reason: string | null | undefined, + fallback: string, +): string { + const normalized = reason?.replace(/\s+/g, " ").trim() || fallback; + return normalized.slice(0, MAX_CONSOLE_REASON_LENGTH); +} + +export function displayedConsoleSource( + state: ConsoleDisplayState, +): ConsoleDisplaySource | null { + switch (state.kind) { + case "ready": + case "stale": + return state.source; + case "loading": + case "unavailable": + case "failed": + return null; + } +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 16cf57efa..1200041a7 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -555,6 +555,56 @@ Deno.test("Worker Console uses protocol observation events without transcript fe ); }); +Deno.test("Worker Console models availability separately from projected lines", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + import.meta.url, + ), + ); + const displayState = await Deno.readTextFile( + new URL("./ConsoleDisplayState.svelte", import.meta.url), + ); + const displayModel = await Deno.readTextFile( + new URL("./console-display-state.ts", import.meta.url), + ); + + assert( + consolePage.includes("consoleDisplayState") && + consolePage.includes("pendingInitialSnapshotApplication") && + consolePage.includes('source: "retained"') && + consolePage.includes('source: "live"') && + consolePage.includes("completeInitialSnapshot(") && + consolePage.includes("showConsoleUnavailable(") && + consolePage.includes("retryConsoleLoad") && + !consolePage.includes( + "{#if lines.length === 0}\n

No console output", + ), + "Console availability must advance on snapshot projection rather than infer loading, failure, or empty history from line count", + ); + assert( + displayModel.includes('kind: "loading"') && + displayModel.includes('kind: "ready"') && + displayModel.includes('kind: "unavailable"') && + displayModel.includes('kind: "failed"') && + displayModel.includes('kind: "stale"') && + displayModel.includes("MAX_CONSOLE_REASON_LENGTH = 240"), + "Console display state must explicitly model loading, ready, unavailable, failed, and stale outcomes with bounded reasons", + ); + assert( + displayState.includes('role="status"') && + displayState.includes('role="alert"') && + displayState.includes('aria-live="polite"') && + displayState.includes("prefers-reduced-motion: no-preference") && + displayState.includes("Read-only retained conversation") && + displayState.includes( + "No console items match the current display mode.", + ) && + displayState.includes(">Retry"), + "Console state presentation must expose accessible loading/errors, retained and filtered-empty copy, retry, and reduced-motion-safe animation", + ); +}); + Deno.test("Worker Console owns its narrower centered shell width", async () => { const page = await Deno.readTextFile( new URL( diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 8f826cc0e..2b5abd0ac 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1,6 +1,7 @@ @@ -1836,9 +1992,14 @@

{workerError}

{/if} - {#if lines.length === 0} -

No console output is available for this Worker yet.

- {:else} + 0} + hasUnfilteredContent={selectedConsoleProjection.lines.length > 0} + onRetry={retryConsoleLoad} + /> + + {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
    {#each lines as item (item.id)} @@ -2182,6 +2343,9 @@ } .console-card { + display: grid; + align-content: start; + gap: var(--space-4); min-height: 100%; } diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts new file mode 100644 index 000000000..7035e6a37 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -0,0 +1,349 @@ +// @vitest-environment happy-dom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import type { + Event as ProtocolEvent, + SessionSnapshot, +} from "$lib/generated/protocol"; +import type { Worker } from "$lib/workspace/sidebar/types"; +import ConsolePage from "./+page.svelte"; + +const multiplexer = vi.hoisted(() => { + type Listener = { + onFrame(frame: unknown): void; + onStatus?(status: "connecting" | "open" | "closed", message?: string): void; + }; + const listeners: Listener[] = []; + return { + listeners, + close: vi.fn(), + sendWorkerMethod: vi.fn(), + subscribe: vi.fn((_selector: unknown, listener: Listener) => { + listeners.push(listener); + listener.onStatus?.("connecting"); + return { + close: multiplexer.close, + sendWorkerMethod: multiplexer.sendWorkerMethod, + }; + }), + }; +}); + +vi.mock("$lib/workspace/multiplexer", () => ({ + workspaceMultiplexer: () => ({ subscribe: multiplexer.subscribe }), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function worker(workerId = "worker-a", runtimeId = "runtime-a"): Worker { + return { + worker_id: workerId, + runtime_id: runtimeId, + resource_key: `worker:${workerId}`, + display_name: workerId, + label: workerId, + state: "idle", + host_id: "host-a", + profile: "default", + tags: [], + diagnostics: [], + capabilities: { + can_stop: true, + can_spawn_followup: true, + }, + workspace: { visibility: "workspace", identity: "workspace-a" }, + implementation: { kind: "builtin", display_hint: "test" }, + workdir_attachments: [], + } as unknown as Worker; +} + +function pageData(workerId = "worker-a", runtimeId = "runtime-a") { + return { + workspaceId: "workspace-a", + runtimeId, + workerId, + worker: worker(workerId, runtimeId), + workerError: null, + }; +} + +function emptySession(): SessionSnapshot { + return { + pending_submissions: { + revision: 0, + notification_count: 0, + head_id: null, + submissions: [], + }, + entries: [], + }; +} + +function sessionWithUserMessage(content: string): SessionSnapshot { + return { + ...emptySession(), + entries: [{ + kind: "user_input", + segments: [{ kind: "text", content }], + entry_id: `entry-${content}`, + timestamp: 1, + provenance: "human_input", + derived_from: [], + }], + }; +} + +function snapshotEvent(session = emptySession()): ProtocolEvent { + return { + event: "snapshot", + data: { + session, + greeting: { + worker_name: "worker-a", + cwd: "", + provider: "test", + model: "test", + scope_summary: "test", + tools: [], + context_window: 0, + context_tokens: 0, + }, + state: { last_command_id: 0, state: { kind: "idle" } }, + in_flight: { blocks: [], commands: [], compaction: null }, + internal_workers: [], + }, + }; +} + +function subscribedFrame(session = emptySession()) { + return { + frame: "response", + message: { + result: "subscribed", + payload: { + snapshot: { + topic: "worker_protocol", + data: { events: [snapshotEvent(session)] }, + }, + }, + }, + }; +} + +function latestListener() { + const listener = multiplexer.listeners.at(-1); + if (!listener) throw new Error("missing Worker protocol listener"); + return listener; +} + +async function settleMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + multiplexer.listeners.length = 0; + multiplexer.close.mockClear(); + multiplexer.sendWorkerMethod.mockClear(); + multiplexer.subscribe.mockClear(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test("keeps delayed Session and live initial snapshot work in loading state until projection applies", async () => { + const sessionResponse = deferred(); + vi.stubGlobal("fetch", vi.fn(() => sessionResponse.promise)); + render(ConsolePage, { data: pageData() }); + + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + + sessionResponse.resolve(Response.json({ availability: "live_protocol" })); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + expect(screen.getByRole("status").textContent).toContain( + "Waiting for the initial conversation snapshot", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + + latestListener().onFrame(subscribedFrame(emptySession())); + expect(await screen.findByText("No conversation to display")).not.toBeNull(); + expect(screen.getByText("This Worker view has no conversation history yet.")) + .not + .toBeNull(); +}); + +test("applies retained empty and nonempty snapshots before showing their read-only result", async () => { + const response = deferred(); + const nextResponse = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const pending = String(input).includes("worker-b") + ? nextResponse.promise + : response.promise; + return pending.then((value) => value.clone()); + }), + ); + const view = render(ConsolePage, { data: pageData() }); + + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + response.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: emptySession(), + })); + expect(await screen.findByText("Read-only retained conversation")).not + .toBeNull(); + expect(screen.getByText("No conversation to display")).not.toBeNull(); + + await view.rerender({ data: pageData("worker-b", "runtime-b") }); + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); + nextResponse.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: sessionWithUserMessage("retained message"), + })); + + expect(await screen.findByText("retained message")).not.toBeNull(); + expect(screen.getByText("Read-only retained conversation")).not.toBeNull(); + expect(screen.queryByText("No conversation to display")).toBeNull(); +}); + +test("shows bounded initial failures and retries explicitly", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(Response.json({ + availability: "retained_snapshot", + snapshot: emptySession(), + })); + vi.stubGlobal("fetch", fetchMock); + render(ConsolePage, { data: pageData() }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Unable to load conversation"); + expect(alert.textContent).toContain("failed to observe Worker Session (503)"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("No conversation to display")).not.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +test("distinguishes typed unavailability and an initial live connection closure", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(Response.json({ + availability: "unavailable", + message: "retained state expired", + })) + .mockResolvedValueOnce(Response.json({ availability: "live_protocol" })); + vi.stubGlobal("fetch", fetchMock); + render(ConsolePage, { data: pageData() }); + + expect((await screen.findByRole("alert")).textContent).toContain( + "Conversation unavailable retained state expired", + ); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onStatus?.("closed", "subscription ended before snapshot"); + + expect((await screen.findByRole("alert")).textContent).toContain( + "subscription ended before snapshot", + ); + expect(screen.queryByText("No conversation to display")).toBeNull(); +}); + +test("preserves rendered content while reconnecting", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(Response.json({ availability: "live_protocol" })) + ), + ); + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("kept message")), + ); + expect(await screen.findByText("kept message")).not.toBeNull(); + + latestListener().onStatus?.("closed", "Workspace subscription disconnected"); + expect(await screen.findByText("Conversation updates are unavailable")).not + .toBeNull(); + expect(screen.getByText("kept message")).not.toBeNull(); + expect(screen.queryByText("No conversation to display")).toBeNull(); + + latestListener().onStatus?.("connecting", "resubscribing"); + expect(await screen.findByText("Refreshing conversation")).not.toBeNull(); + expect(screen.getByText("kept message")).not.toBeNull(); +}); + +test("route changes clear prior content and fence stale Session responses", async () => { + const staleReload = deferred(); + const current = deferred(); + let firstWorkerLoads = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).includes("worker-b")) { + return current.promise.then((value) => value.clone()); + } + firstWorkerLoads += 1; + if (firstWorkerLoads === 1) { + return Promise.resolve(Response.json({ availability: "live_protocol" })); + } + return staleReload.promise.then((value) => value.clone()); + }); + vi.stubGlobal("fetch", fetchMock); + const view = render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("previous worker content")), + ); + expect(await screen.findByText("previous worker content")).not.toBeNull(); + + latestListener().onStatus?.("closed", "reload old worker"); + await fireEvent.click( + await screen.findByRole("button", { name: "Retry" }), + ); + await waitFor(() => expect(firstWorkerLoads).toBe(2)); + await view.rerender({ data: pageData("worker-b", "runtime-b") }); + expect(screen.queryByText("previous worker content")).toBeNull(); + expect(screen.getByRole("status").textContent).toContain( + "Loading conversation", + ); + + current.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: sessionWithUserMessage("current worker content"), + })); + expect(await screen.findByText("current worker content")).not.toBeNull(); + + staleReload.resolve(Response.json({ + availability: "unavailable", + message: "stale worker error", + })); + await settleMicrotasks(); + expect(screen.queryByText("stale worker error")).toBeNull(); + expect(screen.queryByText("previous worker content")).toBeNull(); + expect(screen.getByText("current worker content")).not.toBeNull(); +});