From 51b381a70186a3b0b8f101403e705a13f446bb34 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 21 Aug 2026 07:12:37 +0900 Subject: [PATCH] feat: switch Web SubWorker views --- .../lib/workspace/console/ConsoleTasks.svelte | 98 ++++++++++- .../src/lib/workspace/console/model.test.ts | 93 ++++++++++ .../src/lib/workspace/console/model.ts | 59 +++++-- .../console/worker-console.ui.test.ts | 43 ++++- .../workers/[workerId]/console/+page.svelte | 162 ++++++++++++------ 5 files changed, 389 insertions(+), 66 deletions(-) diff --git a/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte b/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte index a78285d9..5c0a57bc 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte +++ b/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte @@ -1,12 +1,26 @@ -{#if mode === "mini" && tasks.length > 0} +{#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1)}
{#each activeTasks as task (task.taskid)}
@@ -38,8 +52,25 @@ {task.subject.split("\n", 1)[0]}
{/each} -
- {counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted} +
+ + {counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted} + + {#if workerViews.length > 1} + + + {#each workerViews as view, index (view.sessionId ?? "main")} + {#if index > 0}{/if} + + {/each} + + + {/if}
{:else if mode === "pane"} @@ -95,12 +126,69 @@ } .task-mini-row, - .task-heading { + .task-heading, + .task-summary-row { display: flex; min-width: 0; gap: 0.5rem; } + .task-summary-row { + align-items: baseline; + } + + .task-summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .worker-view-tabs { + display: flex; + flex: 0 1 auto; + min-width: 0; + max-width: 60%; + margin-left: auto; + overflow-x: auto; + color: var(--text-muted); + scrollbar-width: none; + white-space: nowrap; + } + + .worker-view-tabs::-webkit-scrollbar { + display: none; + } + + .worker-view-tabs button { + flex: 0 0 auto; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + } + + .worker-view-tabs button:hover, + .worker-view-tabs button:focus-visible { + color: var(--text); + } + + .worker-view-tabs button:focus-visible { + outline: 1px solid currentcolor; + outline-offset: 2px; + } + + .worker-view-tabs button.active { + color: var(--accent); + font-weight: 700; + } + .task-mark, .task-id { flex: 0 0 auto; diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 450bdc74..ccda222c 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -2,11 +2,14 @@ import type { Event } from "$lib/generated/protocol"; import { type ConsoleEventInput, type ConsoleLine, + consoleWorkerViews, createConsoleProjector, isConsoleProjectionEvent, projectConsole, projectConsoleLines, projectOverviewLines, + resolveConsoleViewScrollTop, + resolveConsoleWorkerView, segmentsToText, selectConsoleTimelineLines, workerConsoleHref, @@ -1309,6 +1312,96 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { }, }]); assertEquals(projection.internalWorkers[0].console.lines.length, 1); + + const views = consoleWorkerViews(projection); + assertEquals(views.map((view) => [view.sessionId, view.label]), [ + [null, "main"], + ["child-session", "research"], + ]); + assertEquals( + resolveConsoleWorkerView(projection, "child-session").console.lines[0].body, + "child output", + ); + assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null); +}); + +Deno.test("console Worker views expose only direct Internal Workers", () => { + const projector = createConsoleProjector(); + projector.append([{ + eventId: "nested", + event: { + event: "internal_worker", + data: { + worker: { + session_id: "child-session", + name: "research", + parent_session_id: "parent-session", + kind: "sub_worker", + }, + revision: 1, + event: { + event: "internal_worker", + data: { + worker: { + session_id: "grandchild-session", + name: "nested", + parent_session_id: "child-session", + kind: "sub_worker", + }, + revision: 1, + event: { event: "status", data: { status: "running" } }, + }, + }, + }, + }, + }]); + + projector.append([{ + eventId: "peer", + event: { + event: "internal_worker", + data: { + worker: { + session_id: "peer-other", + name: "research", + parent_session_id: "parent-session", + kind: "sub_worker", + }, + revision: 1, + event: { event: "status", data: { status: "idle" } }, + }, + }, + }]); + + const projection = projector.snapshot(); + const views = consoleWorkerViews(projection); + assertEquals(views.map((view) => view.sessionId), [ + null, + "child-session", + "peer-other", + ]); + assertEquals(views[1].label, "research · ession"); + assertEquals(views[2].label, "research · -other"); + assertEquals( + resolveConsoleWorkerView(projection, "grandchild-session").sessionId, + null, + ); +}); + +Deno.test("console Worker view scroll restores manual offsets and auto-follow", () => { + assertEquals(resolveConsoleViewScrollTop(undefined, 1000, 200), 1000); + assertEquals( + resolveConsoleViewScrollTop({ top: 100, autoFollow: true }, 1000, 200), + 1000, + ); + assertEquals( + resolveConsoleViewScrollTop({ top: 300, autoFollow: false }, 1000, 200), + 300, + ); + assertEquals( + resolveConsoleViewScrollTop({ top: 900, autoFollow: false }, 1000, 200), + 800, + ); }); Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => { diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index ea383d76..18d0550b 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -87,18 +87,57 @@ export type InternalWorkerProjection = { console: ConsoleProjection; }; -export type FlattenedInternalWorkerProjection = InternalWorkerProjection & { - depth: number; +export type ConsoleViewScroll = { + top: number; + autoFollow: boolean; }; -export function flattenInternalWorkers( - workers: InternalWorkerProjection[], - depth = 0, -): FlattenedInternalWorkerProjection[] { - return workers.flatMap((worker) => [ - { ...worker, depth }, - ...flattenInternalWorkers(worker.console.internalWorkers, depth + 1), - ]); +export function resolveConsoleViewScrollTop( + state: ConsoleViewScroll | undefined, + scrollHeight: number, + clientHeight: number, +): number { + if (!state || state.autoFollow) return scrollHeight; + return Math.min(state.top, Math.max(0, scrollHeight - clientHeight)); +} + +export type ConsoleWorkerView = { + sessionId: string | null; + label: string; + console: ConsoleProjection; +}; + +export function consoleWorkerViews( + projection: ConsoleProjection, +): ConsoleWorkerView[] { + const labels = projection.internalWorkers.map((worker) => + worker.worker.name || "subworker" + ); + const labelCounts = new Map(); + for (const label of labels) { + labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); + } + return [ + { sessionId: null, label: "main", console: projection }, + ...projection.internalWorkers.map((worker, index) => { + const label = labels[index] ?? "subworker"; + return { + sessionId: worker.worker.session_id, + label: labelCounts.get(label) === 1 + ? label + : `${label} · ${worker.worker.session_id.slice(-6)}`, + console: worker.console, + }; + }), + ]; +} + +export function resolveConsoleWorkerView( + projection: ConsoleProjection, + selectedSessionId: string | null, +): ConsoleWorkerView { + const views = consoleWorkerViews(projection); + return views.find((view) => view.sessionId === selectedSessionId) ?? views[0]; } export type ConsoleProjection = { 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 67c9ef34..fa30d021 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 @@ -797,7 +797,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async () assert( consolePage.includes("ConsoleTasks") && - consolePage.includes("consoleProjection.tasks") && + consolePage.includes("selectedConsoleProjection.tasks") && consolePage.includes("taskPaneOpen"), "Console should expose the projected task store through its existing client model", ); @@ -818,3 +818,44 @@ Deno.test("Web Console renders the client-projected Worker task store", async () "Task projection should replay the protocol client-side without adding a task API", ); }); + +Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", + import.meta.url, + ), + ); + const tasksComponent = await Deno.readTextFile( + new URL("./ConsoleTasks.svelte", import.meta.url), + ); + const consoleModel = await Deno.readTextFile( + new URL("./model.ts", import.meta.url), + ); + + assert( + consolePage.includes("selectedWorkerViewSessionId") && + consolePage.includes("selectConsoleWorkerView") && + consolePage.includes("selectedConsoleProjection.lines") && + consolePage.includes("selectedConsoleProjection.tasks") && + consolePage.includes("onSelectWorkerView") && + consolePage.includes("selectConsoleWorkerView(resolvedSessionId, false)") && + consolePage.includes("consoleWorkerViewSelectionIsResolved") && + !consolePage.includes("internal-worker-pane") && + !consolePage.includes("flattenInternalWorkers"), + "Console should render one selected transcript/task projection without appending Internal Worker panes", + ); + assert( + tasksComponent.includes('role="group"') && + tasksComponent.includes("aria-pressed") && + tasksComponent.includes("onclick") && + tasksComponent.includes("tasks.length > 0 || workerViews.length > 1"), + "Tasks summary should expose a clickable and accessible Worker view selector even with zero tasks", + ); + assert( + consoleModel.includes("consoleWorkerViews") && + consoleModel.includes("projection.internalWorkers.map") && + consoleModel.includes("resolveConsoleWorkerView"), + "Worker view selection should use direct Internal Worker session identities with main fallback", + ); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index 1eea394c..d2a75564 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -19,15 +19,18 @@ import { fitTextarea } from "$lib/workspace/console/textarea-fit"; import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts"; import { + consoleWorkerViews, createConsoleProjector, - flattenInternalWorkers, isConsoleProjectionEvent, projectConsoleLines, + resolveConsoleViewScrollTop, + resolveConsoleWorkerView, selectConsoleTimelineLines, type ConsoleEventInput, type ConsoleLine, type ConsoleProjection, type ConsoleViewMode, + type ConsoleViewScroll, } from "$lib/workspace/console/model"; import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol"; import { workspaceApiPath } from "$lib/workspace/api/http"; @@ -122,6 +125,8 @@ let streamDiagnostics = $state([]); let workerDetailsOpen = $state(false); let taskPaneOpen = $state(false); + let selectedWorkerViewSessionId = $state(null); + let workerViewSelectionGeneration = 0; let timelineOpen = $state(false); let consoleViewMode = $state("overview"); let consoleBodyElement: HTMLElement | null = null; @@ -129,6 +134,7 @@ let timelineRailDragCleanup: (() => void) | null = null; let autoFollowConsole = $state(true); let consoleScroll = $state({ top: 0, height: 1, client: 1 }); + const consoleViewScroll = new Map(); const eventObservedAtById = new Map(); let nextEventObservedAtVersion = 0; let eventObservedAtVersion = $state(0); @@ -156,13 +162,18 @@ const consoleTarget = $derived({ workspaceId, runtimeId, workerId }); + const workerViews = $derived(consoleWorkerViews(consoleProjection)); + const selectedWorkerView = $derived( + resolveConsoleWorkerView( + consoleProjection, + selectedWorkerViewSessionId, + ), + ); + const selectedConsoleProjection = $derived(selectedWorkerView.console); const lines = $derived( - projectConsoleLines(consoleProjection.lines, consoleViewMode), - ); - const tasks = $derived(consoleProjection.tasks); - const internalWorkers = $derived( - flattenInternalWorkers(consoleProjection.internalWorkers), + projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode), ); + const tasks = $derived(selectedConsoleProjection.tasks); const timelineLayout = $derived( buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll), ); @@ -1085,6 +1096,47 @@ : value.replaceAll('"', '\\"'); } + function consoleWorkerViewKey(sessionId: string | null): string { + return sessionId === null ? "main" : `internal:${sessionId}`; + } + + function consoleWorkerViewSelectionIsResolved(): boolean { + return selectedWorkerViewSessionId === selectedWorkerView.sessionId; + } + + function rememberConsoleWorkerViewScroll() { + if (!consoleBodyElement || !consoleWorkerViewSelectionIsResolved()) return; + consoleViewScroll.set(consoleWorkerViewKey(selectedWorkerView.sessionId), { + top: consoleBodyElement.scrollTop, + autoFollow: autoFollowConsole, + }); + } + + async function selectConsoleWorkerView( + sessionId: string | null, + rememberCurrent = true, + ) { + if (sessionId === selectedWorkerViewSessionId) return; + const generation = ++workerViewSelectionGeneration; + if (rememberCurrent) rememberConsoleWorkerViewScroll(); + const target = workerViews.find((view) => view.sessionId === sessionId) ?? + workerViews[0]; + const targetScroll = consoleViewScroll.get( + consoleWorkerViewKey(target.sessionId), + ); + autoFollowConsole = targetScroll?.autoFollow ?? true; + selectedWorkerViewSessionId = target.sessionId; + await tick(); + if (generation !== workerViewSelectionGeneration) return; + if (!consoleBodyElement) return; + consoleBodyElement.scrollTop = resolveConsoleViewScrollTop( + targetScroll, + consoleBodyElement.scrollHeight, + consoleBodyElement.clientHeight, + ); + updateConsoleScrollMetrics(); + } + function updateConsoleScrollMetrics() { if (!consoleBodyElement) { return; @@ -1104,21 +1156,30 @@ } function handleConsoleScroll() { - if (!consoleBodyElement) { + if (!consoleWorkerViewSelectionIsResolved() || !consoleBodyElement) { return; } autoFollowConsole = isNearConsoleBottom(consoleBodyElement); updateConsoleScrollMetrics(); + rememberConsoleWorkerViewScroll(); } async function scrollConsoleToBottom() { + if (!consoleWorkerViewSelectionIsResolved()) return; + const sessionId = selectedWorkerView.sessionId; await tick(); - if (!consoleBodyElement) { + if ( + !consoleBodyElement || + !autoFollowConsole || + !consoleWorkerViewSelectionIsResolved() || + selectedWorkerView.sessionId !== sessionId + ) { return; } consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight; updateConsoleScrollMetrics(); autoFollowConsole = true; + rememberConsoleWorkerViewScroll(); } const scrollFollowKey = $derived( @@ -1132,10 +1193,32 @@ $effect(() => { scrollFollowKey; + if (!consoleWorkerViewSelectionIsResolved()) return; if (autoFollowConsole) { void scrollConsoleToBottom(); } else { - tick().then(updateConsoleScrollMetrics); + const sessionId = selectedWorkerView.sessionId; + tick().then(() => { + if ( + consoleWorkerViewSelectionIsResolved() && + selectedWorkerView.sessionId === sessionId + ) { + updateConsoleScrollMetrics(); + } + }); + } + }); + + $effect(() => { + const activeViewKeys = new Set( + workerViews.map((view) => consoleWorkerViewKey(view.sessionId)), + ); + for (const key of consoleViewScroll.keys()) { + if (!activeViewKeys.has(key)) consoleViewScroll.delete(key); + } + const resolvedSessionId = selectedWorkerView.sessionId; + if (resolvedSessionId !== selectedWorkerViewSessionId) { + void selectConsoleWorkerView(resolvedSessionId, false); } }); @@ -1147,6 +1230,10 @@ const target = consoleTarget; const targetWorker = data.worker; const targetWorkerError = data.workerError; + workerViewSelectionGeneration += 1; + selectedWorkerViewSessionId = null; + consoleViewScroll.clear(); + autoFollowConsole = true; resetObservedEvents(); taskPaneOpen = false; worker = targetWorker; @@ -1282,7 +1369,10 @@ bind:this={consoleBodyElement} onscroll={handleConsoleScroll} > -
+
{#if workerError}

{workerError}

{/if} @@ -1297,28 +1387,6 @@ {/if}
- - {#each internalWorkers as internal (internal.worker.session_id)} -
-
- {internal.worker.name} - {internal.console.status ?? "unknown"} -
- {#if internal.console.lines.length === 0} -

No output yet.

- {:else} -
    - {#each projectConsoleLines(internal.console.lines, consoleViewMode) as item (item.id)} - - {/each} -
- {/if} -
- {/each} {/if} - + ({ + sessionId, + label, + }))} + selectedWorkerViewSessionId={selectedWorkerView.sessionId} + onSelectWorkerView={(sessionId) => { + void selectConsoleWorkerView(sessionId); + }} + />
@@ -1869,23 +1948,6 @@ margin-right: auto; } - .internal-worker-pane { - margin: 0.75rem 0 0 calc((var(--internal-worker-depth) + 1) * 1rem); - border-left: 3px solid var(--color-border-strong, currentColor); - } - - .internal-worker-header { - display: flex; - justify-content: space-between; - gap: 1rem; - margin-bottom: 0.5rem; - font-family: var(--font-mono); - } - - .internal-worker-header span { - color: var(--color-text-muted); - } - @media (max-width: 960px) { .console-history.with-task-pane { grid-template-columns: minmax(0, 1fr);