feat: switch Web SubWorker views

This commit is contained in:
2026-08-21 07:12:37 +09:00
parent bea121ade0
commit 51b381a701
5 changed files with 389 additions and 66 deletions
@@ -1,12 +1,26 @@
<script lang="ts">
import { taskCounts, type ConsoleTask } from "./tasks.ts";
type WorkerViewTab = {
sessionId: string | null;
label: string;
};
type Props = {
tasks: ConsoleTask[];
mode: "mini" | "pane";
workerViews?: WorkerViewTab[];
selectedWorkerViewSessionId?: string | null;
onSelectWorkerView?: (sessionId: string | null) => void;
};
let { tasks, mode }: Props = $props();
let {
tasks,
mode,
workerViews = [],
selectedWorkerViewSessionId = null,
onSelectWorkerView = () => {},
}: Props = $props();
const counts = $derived(taskCounts(tasks));
const activeTasks = $derived(
tasks
@@ -28,7 +42,7 @@
}
</script>
{#if mode === "mini" && tasks.length > 0}
{#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1)}
<section class="task-mini" aria-label="Worker task summary">
{#each activeTasks as task (task.taskid)}
<div class="task-mini-row">
@@ -38,8 +52,25 @@
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
</div>
{/each}
<div class="task-summary">
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
<div class="task-summary-row">
<span class="task-summary">
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
</span>
{#if workerViews.length > 1}
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
<span aria-hidden="true">[ </span>
{#each workerViews as view, index (view.sessionId ?? "main")}
{#if index > 0}<span aria-hidden="true"> | </span>{/if}
<button
type="button"
aria-pressed={view.sessionId === selectedWorkerViewSessionId}
class:active={view.sessionId === selectedWorkerViewSessionId}
onclick={() => onSelectWorkerView(view.sessionId)}
>{view.label}</button>
{/each}
<span aria-hidden="true"> ]</span>
</span>
{/if}
</div>
</section>
{: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;
@@ -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", () => {
@@ -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<string, number>();
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 = {
@@ -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",
);
});
@@ -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<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
let selectedWorkerViewSessionId = $state<string | null>(null);
let workerViewSelectionGeneration = 0;
let timelineOpen = $state(false);
let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null;
@@ -129,6 +134,7 @@
let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true);
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
const consoleViewScroll = new Map<string, ConsoleViewScroll>();
const eventObservedAtById = new Map<string, number>();
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}
>
<article class="card console-card worker-console-card">
<article
class="card console-card worker-console-card"
aria-label={`${selectedWorkerView.label} transcript`}
>
{#if workerError}
<p class="error">{workerError}</p>
{/if}
@@ -1297,28 +1387,6 @@
</ol>
{/if}
</article>
{#each internalWorkers as internal (internal.worker.session_id)}
<section
class="card internal-worker-pane"
style={`--internal-worker-depth: ${internal.depth}`}
aria-label={`SubWorker ${internal.worker.name}`}
>
<header class="internal-worker-header">
<strong>{internal.worker.name}</strong>
<span>{internal.console.status ?? "unknown"}</span>
</header>
{#if internal.console.lines.length === 0}
<p>No output yet.</p>
{:else}
<ol class="console-log">
{#each projectConsoleLines(internal.console.lines, consoleViewMode) as item (item.id)}
<ConsoleLineItem {item} />
{/each}
</ol>
{/if}
</section>
{/each}
</div>
<ConsoleTimeline
@@ -1428,7 +1496,18 @@
/>
{/if}
<ConsoleTasks {tasks} mode="mini" />
<ConsoleTasks
{tasks}
mode="mini"
workerViews={workerViews.map(({ sessionId, label }) => ({
sessionId,
label,
}))}
selectedWorkerViewSessionId={selectedWorkerView.sessionId}
onSelectWorkerView={(sessionId) => {
void selectConsoleWorkerView(sessionId);
}}
/>
<form class="console-composer card" onsubmit={sendMessage}>
<div class="composer-input-shell">
@@ -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);