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"}
+
+
+
+ {state.kind === "unavailable"
+ ? "Conversation unavailable"
+ : "Unable to load conversation"}
+
+ {state.reason}
+
+ Retry
+
+{:else}
+ {#if state.kind === "stale"}
+
+
+
+ {state.phase === "reconnecting"
+ ? "Refreshing conversation"
+ : "Conversation updates are unavailable"}
+
+ {state.reason} Previously loaded conversation is preserved.
+
+ Retry
+
+ {: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();
+});