feat: switch Web SubWorker views
This commit is contained in:
@@ -1,12 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { taskCounts, type ConsoleTask } from "./tasks.ts";
|
import { taskCounts, type ConsoleTask } from "./tasks.ts";
|
||||||
|
|
||||||
|
type WorkerViewTab = {
|
||||||
|
sessionId: string | null;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tasks: ConsoleTask[];
|
tasks: ConsoleTask[];
|
||||||
mode: "mini" | "pane";
|
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 counts = $derived(taskCounts(tasks));
|
||||||
const activeTasks = $derived(
|
const activeTasks = $derived(
|
||||||
tasks
|
tasks
|
||||||
@@ -28,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<section class="task-mini" aria-label="Worker task summary">
|
||||||
{#each activeTasks as task (task.taskid)}
|
{#each activeTasks as task (task.taskid)}
|
||||||
<div class="task-mini-row">
|
<div class="task-mini-row">
|
||||||
@@ -38,8 +52,25 @@
|
|||||||
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
|
<span class="task-subject">{task.subject.split("\n", 1)[0]}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="task-summary">
|
<div class="task-summary-row">
|
||||||
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if mode === "pane"}
|
{:else if mode === "pane"}
|
||||||
@@ -95,12 +126,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-mini-row,
|
.task-mini-row,
|
||||||
.task-heading {
|
.task-heading,
|
||||||
|
.task-summary-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.5rem;
|
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-mark,
|
||||||
.task-id {
|
.task-id {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import type { Event } from "$lib/generated/protocol";
|
|||||||
import {
|
import {
|
||||||
type ConsoleEventInput,
|
type ConsoleEventInput,
|
||||||
type ConsoleLine,
|
type ConsoleLine,
|
||||||
|
consoleWorkerViews,
|
||||||
createConsoleProjector,
|
createConsoleProjector,
|
||||||
isConsoleProjectionEvent,
|
isConsoleProjectionEvent,
|
||||||
projectConsole,
|
projectConsole,
|
||||||
projectConsoleLines,
|
projectConsoleLines,
|
||||||
projectOverviewLines,
|
projectOverviewLines,
|
||||||
|
resolveConsoleViewScrollTop,
|
||||||
|
resolveConsoleWorkerView,
|
||||||
segmentsToText,
|
segmentsToText,
|
||||||
selectConsoleTimelineLines,
|
selectConsoleTimelineLines,
|
||||||
workerConsoleHref,
|
workerConsoleHref,
|
||||||
@@ -1309,6 +1312,96 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
|
|||||||
},
|
},
|
||||||
}]);
|
}]);
|
||||||
assertEquals(projection.internalWorkers[0].console.lines.length, 1);
|
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", () => {
|
Deno.test("parent snapshot authoritatively replaces Internal Worker projections", () => {
|
||||||
|
|||||||
@@ -87,18 +87,57 @@ export type InternalWorkerProjection = {
|
|||||||
console: ConsoleProjection;
|
console: ConsoleProjection;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FlattenedInternalWorkerProjection = InternalWorkerProjection & {
|
export type ConsoleViewScroll = {
|
||||||
depth: number;
|
top: number;
|
||||||
|
autoFollow: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function flattenInternalWorkers(
|
export function resolveConsoleViewScrollTop(
|
||||||
workers: InternalWorkerProjection[],
|
state: ConsoleViewScroll | undefined,
|
||||||
depth = 0,
|
scrollHeight: number,
|
||||||
): FlattenedInternalWorkerProjection[] {
|
clientHeight: number,
|
||||||
return workers.flatMap((worker) => [
|
): number {
|
||||||
{ ...worker, depth },
|
if (!state || state.autoFollow) return scrollHeight;
|
||||||
...flattenInternalWorkers(worker.console.internalWorkers, depth + 1),
|
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 = {
|
export type ConsoleProjection = {
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
|
|||||||
|
|
||||||
assert(
|
assert(
|
||||||
consolePage.includes("ConsoleTasks") &&
|
consolePage.includes("ConsoleTasks") &&
|
||||||
consolePage.includes("consoleProjection.tasks") &&
|
consolePage.includes("selectedConsoleProjection.tasks") &&
|
||||||
consolePage.includes("taskPaneOpen"),
|
consolePage.includes("taskPaneOpen"),
|
||||||
"Console should expose the projected task store through its existing client model",
|
"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",
|
"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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+112
-50
@@ -19,15 +19,18 @@
|
|||||||
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
|
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
|
||||||
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
||||||
import {
|
import {
|
||||||
|
consoleWorkerViews,
|
||||||
createConsoleProjector,
|
createConsoleProjector,
|
||||||
flattenInternalWorkers,
|
|
||||||
isConsoleProjectionEvent,
|
isConsoleProjectionEvent,
|
||||||
projectConsoleLines,
|
projectConsoleLines,
|
||||||
|
resolveConsoleViewScrollTop,
|
||||||
|
resolveConsoleWorkerView,
|
||||||
selectConsoleTimelineLines,
|
selectConsoleTimelineLines,
|
||||||
type ConsoleEventInput,
|
type ConsoleEventInput,
|
||||||
type ConsoleLine,
|
type ConsoleLine,
|
||||||
type ConsoleProjection,
|
type ConsoleProjection,
|
||||||
type ConsoleViewMode,
|
type ConsoleViewMode,
|
||||||
|
type ConsoleViewScroll,
|
||||||
} from "$lib/workspace/console/model";
|
} from "$lib/workspace/console/model";
|
||||||
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
||||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
@@ -122,6 +125,8 @@
|
|||||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||||
let workerDetailsOpen = $state(false);
|
let workerDetailsOpen = $state(false);
|
||||||
let taskPaneOpen = $state(false);
|
let taskPaneOpen = $state(false);
|
||||||
|
let selectedWorkerViewSessionId = $state<string | null>(null);
|
||||||
|
let workerViewSelectionGeneration = 0;
|
||||||
let timelineOpen = $state(false);
|
let timelineOpen = $state(false);
|
||||||
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
let consoleViewMode = $state<ConsoleViewMode>("overview");
|
||||||
let consoleBodyElement: HTMLElement | null = null;
|
let consoleBodyElement: HTMLElement | null = null;
|
||||||
@@ -129,6 +134,7 @@
|
|||||||
let timelineRailDragCleanup: (() => void) | null = null;
|
let timelineRailDragCleanup: (() => void) | null = null;
|
||||||
let autoFollowConsole = $state(true);
|
let autoFollowConsole = $state(true);
|
||||||
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
|
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
|
||||||
|
const consoleViewScroll = new Map<string, ConsoleViewScroll>();
|
||||||
const eventObservedAtById = new Map<string, number>();
|
const eventObservedAtById = new Map<string, number>();
|
||||||
let nextEventObservedAtVersion = 0;
|
let nextEventObservedAtVersion = 0;
|
||||||
let eventObservedAtVersion = $state(0);
|
let eventObservedAtVersion = $state(0);
|
||||||
@@ -156,13 +162,18 @@
|
|||||||
|
|
||||||
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
|
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(
|
const lines = $derived(
|
||||||
projectConsoleLines(consoleProjection.lines, consoleViewMode),
|
projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode),
|
||||||
);
|
|
||||||
const tasks = $derived(consoleProjection.tasks);
|
|
||||||
const internalWorkers = $derived(
|
|
||||||
flattenInternalWorkers(consoleProjection.internalWorkers),
|
|
||||||
);
|
);
|
||||||
|
const tasks = $derived(selectedConsoleProjection.tasks);
|
||||||
const timelineLayout = $derived(
|
const timelineLayout = $derived(
|
||||||
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
|
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
|
||||||
);
|
);
|
||||||
@@ -1085,6 +1096,47 @@
|
|||||||
: value.replaceAll('"', '\\"');
|
: 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() {
|
function updateConsoleScrollMetrics() {
|
||||||
if (!consoleBodyElement) {
|
if (!consoleBodyElement) {
|
||||||
return;
|
return;
|
||||||
@@ -1104,21 +1156,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleConsoleScroll() {
|
function handleConsoleScroll() {
|
||||||
if (!consoleBodyElement) {
|
if (!consoleWorkerViewSelectionIsResolved() || !consoleBodyElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
autoFollowConsole = isNearConsoleBottom(consoleBodyElement);
|
||||||
updateConsoleScrollMetrics();
|
updateConsoleScrollMetrics();
|
||||||
|
rememberConsoleWorkerViewScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollConsoleToBottom() {
|
async function scrollConsoleToBottom() {
|
||||||
|
if (!consoleWorkerViewSelectionIsResolved()) return;
|
||||||
|
const sessionId = selectedWorkerView.sessionId;
|
||||||
await tick();
|
await tick();
|
||||||
if (!consoleBodyElement) {
|
if (
|
||||||
|
!consoleBodyElement ||
|
||||||
|
!autoFollowConsole ||
|
||||||
|
!consoleWorkerViewSelectionIsResolved() ||
|
||||||
|
selectedWorkerView.sessionId !== sessionId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight;
|
consoleBodyElement.scrollTop = consoleBodyElement.scrollHeight;
|
||||||
updateConsoleScrollMetrics();
|
updateConsoleScrollMetrics();
|
||||||
autoFollowConsole = true;
|
autoFollowConsole = true;
|
||||||
|
rememberConsoleWorkerViewScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollFollowKey = $derived(
|
const scrollFollowKey = $derived(
|
||||||
@@ -1132,10 +1193,32 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
scrollFollowKey;
|
scrollFollowKey;
|
||||||
|
if (!consoleWorkerViewSelectionIsResolved()) return;
|
||||||
if (autoFollowConsole) {
|
if (autoFollowConsole) {
|
||||||
void scrollConsoleToBottom();
|
void scrollConsoleToBottom();
|
||||||
} else {
|
} 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 target = consoleTarget;
|
||||||
const targetWorker = data.worker;
|
const targetWorker = data.worker;
|
||||||
const targetWorkerError = data.workerError;
|
const targetWorkerError = data.workerError;
|
||||||
|
workerViewSelectionGeneration += 1;
|
||||||
|
selectedWorkerViewSessionId = null;
|
||||||
|
consoleViewScroll.clear();
|
||||||
|
autoFollowConsole = true;
|
||||||
resetObservedEvents();
|
resetObservedEvents();
|
||||||
taskPaneOpen = false;
|
taskPaneOpen = false;
|
||||||
worker = targetWorker;
|
worker = targetWorker;
|
||||||
@@ -1282,7 +1369,10 @@
|
|||||||
bind:this={consoleBodyElement}
|
bind:this={consoleBodyElement}
|
||||||
onscroll={handleConsoleScroll}
|
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}
|
{#if workerError}
|
||||||
<p class="error">{workerError}</p>
|
<p class="error">{workerError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1297,28 +1387,6 @@
|
|||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ConsoleTimeline
|
<ConsoleTimeline
|
||||||
@@ -1428,7 +1496,18 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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}>
|
<form class="console-composer card" onsubmit={sendMessage}>
|
||||||
<div class="composer-input-shell">
|
<div class="composer-input-shell">
|
||||||
@@ -1869,23 +1948,6 @@
|
|||||||
margin-right: auto;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.console-history.with-task-pane {
|
.console-history.with-task-pane {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user