- {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)}
-
-
- {#if internal.console.lines.length === 0}
- No output yet.
- {:else}
-
- {#each projectConsoleLines(internal.console.lines, consoleViewMode) as item (item.id)}
-
- {/each}
-
- {/if}
-
- {/each}