web: show Worker tasks in Console

This commit is contained in:
2026-08-14 12:46:59 +09:00
parent e582babae3
commit 93eea24420
8 changed files with 780 additions and 26 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts", "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -0,0 +1,183 @@
<script lang="ts">
import { taskCounts, type ConsoleTask } from "./tasks.ts";
type Props = {
tasks: ConsoleTask[];
mode: "mini" | "pane";
};
let { tasks, mode }: Props = $props();
const counts = $derived(taskCounts(tasks));
const activeTasks = $derived(
tasks
.filter((task) => task.status === "pending" || task.status === "inprogress")
.slice(0, 3),
);
function mark(status: ConsoleTask["status"]): string {
switch (status) {
case "pending":
return "[ ]";
case "inprogress":
return "[~]";
case "completed":
return "[x]";
case "deleted":
return "[-]";
}
}
</script>
{#if mode === "mini" && tasks.length > 0}
<section class="task-mini" aria-label="Worker task summary">
{#each activeTasks as task (task.taskid)}
<div class="task-mini-row">
<span class:inprogress={task.status === "inprogress"} class="task-mark">
{mark(task.status)}
</span>
<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>
</section>
{:else if mode === "pane"}
<aside class="task-pane" aria-label="Worker tasks">
<h3>Tasks ({counts.total})</h3>
{#if tasks.length === 0}
<p class="task-empty">(no tasks)</p>
{:else}
<ol class="task-list">
{#each tasks as task (task.taskid)}
{@const subjectLines = task.subject.split("\n")}
<li>
<div class="task-heading">
<span class="task-id">#{task.taskid}</span>
<span
class:inprogress={task.status === "inprogress"}
class:completed={task.status === "completed"}
class:deleted={task.status === "deleted"}
class="task-mark"
>{mark(task.status)}</span>
<span>{subjectLines[0] ?? ""}</span>
</div>
{#each subjectLines.slice(1) as subjectLine}
<div class="task-continuation">{subjectLine}</div>
{/each}
{#if task.description}
{#each task.description.split("\n") as descriptionLine}
<div class="task-continuation task-description">{descriptionLine}</div>
{/each}
{/if}
</li>
{/each}
</ol>
{/if}
</aside>
{/if}
<style>
.task-mini,
.task-pane {
font-family: var(--font-mono);
}
.task-mini {
display: grid;
gap: 0.1rem;
min-width: 0;
margin-bottom: -0.75rem;
padding-inline: 0.75rem;
font-size: 0.8rem;
line-height: 1.35;
}
.task-mini-row,
.task-heading {
display: flex;
min-width: 0;
gap: 0.5rem;
}
.task-mark,
.task-id {
flex: 0 0 auto;
color: var(--text-muted);
white-space: nowrap;
}
.task-mark.inprogress {
color: var(--warning);
font-weight: 700;
}
.task-mark.completed {
color: var(--success);
}
.task-mark.deleted {
color: var(--danger);
}
.task-subject,
.task-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-summary,
.task-id,
.task-empty,
.task-description {
color: var(--text-muted);
}
.task-pane {
min-width: 0;
min-height: 0;
overflow: auto;
padding-inline: 1rem;
border-left: 1px solid var(--line);
}
.task-pane h3 {
margin: 0 0 1rem;
color: var(--accent);
font-size: 0.9rem;
}
.task-empty {
margin: 0;
}
.task-list {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.task-heading {
align-items: baseline;
}
.task-continuation {
padding-left: 4ch;
white-space: pre-wrap;
}
.task-description {
font-size: 0.8rem;
line-height: 1.45;
}
@media (max-width: 900px) {
.task-pane {
display: none;
}
}
</style>
@@ -1126,3 +1126,68 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
"Grep should relativize line-start cwd paths and keep outside paths absolute", "Grep should relativize line-start cwd paths and keep outside paths absolute",
); );
}); });
Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
const projection = projectConsole([
{
eventId: "task-create",
event: {
event: "tool_call_done",
data: {
id: "task-create-1",
name: "TaskCreate",
arguments: JSON.stringify({
subject: "Port Tasks",
description: "Render active Worker tasks",
}),
},
} satisfies Event,
},
{
eventId: "task-update",
event: {
event: "tool_call_done",
data: {
id: "task-update-1",
name: "TaskUpdate",
arguments: JSON.stringify({ taskid: 1, status: "inprogress" }),
},
} satisfies Event,
},
]);
assertEquals(projection.tasks, [{
taskid: 1,
status: "inprogress",
subject: "Port Tasks",
description: "Render active Worker tasks",
}]);
});
Deno.test("snapshot restores TaskStore state from system history", () => {
const taskSnapshot =
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
const event = snapshotEvent("/repo");
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
event.data.entries = [{
kind: "segment_start",
ts: 1,
session_id: "00000000-0000-0000-0000-000000000001",
system_prompt: null,
config: {},
history: [{
kind: "message",
role: "system",
content: [{ kind: "text", text: taskSnapshot }],
}],
}];
const projection = projectConsole([{ eventId: "task-snapshot", event }]);
assertEquals(projection.tasks, [{
taskid: 3,
status: "pending",
subject: "Restored",
description: "From compaction",
}]);
assertEquals(projection.taskNextId, 4);
});
@@ -6,6 +6,11 @@ import type {
Segment, Segment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace/api/http"; import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyTaskSnapshotText,
applyTaskToolCall,
type ConsoleTask,
} from "./tasks.ts";
export type ConsoleLineKind = export type ConsoleLineKind =
| "user" | "user"
@@ -60,6 +65,8 @@ export type ConsoleLine = {
export type ConsoleProjection = { export type ConsoleProjection = {
lines: ConsoleLine[]; lines: ConsoleLine[];
tasks: ConsoleTask[];
taskNextId: number;
status: string | null; status: string | null;
usage: string | null; usage: string | null;
cwd: string | null; cwd: string | null;
@@ -138,6 +145,8 @@ export type ConsoleEventInput = {
export function emptyConsoleProjection(): ConsoleProjection { export function emptyConsoleProjection(): ConsoleProjection {
return { return {
lines: [], lines: [],
tasks: [],
taskNextId: 1,
status: null, status: null,
usage: null, usage: null,
cwd: null, cwd: null,
@@ -189,6 +198,8 @@ export function applyProtocolEvent(
): ConsoleProjection { ): ConsoleProjection {
const next: ConsoleProjection = { const next: ConsoleProjection = {
lines: [...projection.lines], lines: [...projection.lines],
tasks: [...projection.tasks],
taskNextId: projection.taskNextId,
status: projection.status, status: projection.status,
usage: projection.usage, usage: projection.usage,
cwd: projection.cwd, cwd: projection.cwd,
@@ -209,6 +220,7 @@ export function applyProtocolEvent(
break; break;
case "system_item": case "system_item":
next.lines.push(systemItemLine(envelope.eventId, event.data.item)); next.lines.push(systemItemLine(envelope.eventId, event.data.item));
applyTaskSystemItem(next, event.data.item);
break; break;
case "text_delta": case "text_delta":
appendStreaming( appendStreaming(
@@ -267,6 +279,7 @@ export function applyProtocolEvent(
argsStream: event.data.arguments, argsStream: event.data.arguments,
state: "running", state: "running",
}); });
applyTaskTool(next, event.data.name, event.data.arguments);
break; break;
case "tool_result": case "tool_result":
attachToolResult(next, envelope.eventId, event.data.id, { attachToolResult(next, envelope.eventId, event.data.id, {
@@ -291,26 +304,36 @@ export function applyProtocolEvent(
), ),
); );
break; break;
case "snapshot": case "snapshot": {
next.status = event.data.status; next.status = event.data.status;
next.cwd = event.data.greeting.cwd; next.cwd = event.data.greeting.cwd;
next.lines = snapshotLinesFromEntries( const snapshot = snapshotProjectionFromEntries(
envelope.eventId, envelope.eventId,
event.data.entries, event.data.entries,
next.cwd, next.cwd,
); );
next.lines = snapshot.lines;
next.tasks = snapshot.tasks;
next.taskNextId = snapshot.taskNextId;
for (const block of event.data.in_flight?.blocks ?? []) { for (const block of event.data.in_flight?.blocks ?? []) {
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd)); next.lines.push(inFlightLine(envelope.eventId, block, next.cwd));
} }
break; break;
}
case "status": case "status":
next.status = event.data.status; next.status = event.data.status;
break; break;
case "segment_rotated": case "segment_rotated": {
next.lines = snapshotLinesFromEntries(envelope.eventId, [ const segment = snapshotProjectionFromEntries(
event.data.entry, envelope.eventId,
], next.cwd); [event.data.entry],
next.cwd,
);
next.lines = segment.lines;
next.tasks = segment.tasks;
next.taskNextId = segment.taskNextId;
break; break;
}
case "invoke_start": case "invoke_start":
case "turn_start": case "turn_start":
case "turn_end": case "turn_end":
@@ -1111,13 +1134,47 @@ function usageText(
} · cache ${data.cache_read_input_tokens ?? "unknown"}`; } · cache ${data.cache_read_input_tokens ?? "unknown"}`;
} }
function snapshotLinesFromEntries( function applyTaskTool(
projection: ConsoleProjection,
name: string,
argumentsJson: string,
): void {
const state = applyTaskToolCall(
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
name,
argumentsJson,
);
projection.tasks = state.tasks;
projection.taskNextId = state.nextTaskId;
}
function applyTaskSnapshot(projection: ConsoleProjection, text: string): void {
const state = applyTaskSnapshotText(
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
text,
);
projection.tasks = state.tasks;
projection.taskNextId = state.nextTaskId;
}
function applyTaskSystemItem(
projection: ConsoleProjection,
item: unknown,
): void {
if (!isRecord(item)) return;
const body = item["body"];
if (typeof body === "string") applyTaskSnapshot(projection, body);
}
function snapshotProjectionFromEntries(
eventId: string, eventId: string,
entries: unknown[], entries: unknown[],
cwd: string | null, cwd: string | null,
): ConsoleLine[] { ): ConsoleProjection {
const projection: ConsoleProjection = { const projection: ConsoleProjection = {
lines: [], lines: [],
tasks: [],
taskNextId: 1,
status: null, status: null,
usage: null, usage: null,
cwd, cwd,
@@ -1126,7 +1183,7 @@ function snapshotLinesFromEntries(
entries.forEach((entry, index) => entries.forEach((entry, index) =>
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry) applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
); );
return projection.lines; return projection;
} }
function applyLogEntry( function applyLogEntry(
@@ -1153,6 +1210,7 @@ function applyLogEntry(
break; break;
case "system_item": case "system_item":
projection.lines.push(systemItemLine(eventId, entry["item"])); projection.lines.push(systemItemLine(eventId, entry["item"]));
applyTaskSystemItem(projection, entry["item"]);
break; break;
case "assistant_item": case "assistant_item":
case "tool_result": case "tool_result":
@@ -1224,6 +1282,9 @@ function applyLoggedItem(
case "assistant": case "assistant":
projection.lines.push(line(eventId, "assistant", "assistant", body)); projection.lines.push(line(eventId, "assistant", "assistant", body));
break; break;
case "system":
applyTaskSnapshot(projection, body);
break;
default: default:
break; break;
} }
@@ -1238,19 +1299,23 @@ function applyLoggedItem(
} }
break; break;
} }
case "tool_call": case "tool_call": {
const name = stringField(item, "name") ?? "Tool";
const argumentsJson = stringField(item, "arguments") ?? "";
upsertToolCall( upsertToolCall(
projection, projection,
eventId, eventId,
stringField(item, "call_id") ?? eventId, stringField(item, "call_id") ?? eventId,
{ {
name: stringField(item, "name") ?? "Tool", name,
arguments: stringField(item, "arguments") ?? "", arguments: argumentsJson,
argsStream: stringField(item, "arguments") ?? "", argsStream: argumentsJson,
state: "running", state: "running",
}, },
); );
applyTaskTool(projection, name, argumentsJson);
break; break;
}
case "tool_result": case "tool_result":
attachToolResult( attachToolResult(
projection, projection,
@@ -0,0 +1,134 @@
declare const Deno: {
test(name: string, fn: () => void): void;
};
import {
applyTaskSnapshotText,
applyTaskToolCall,
emptyConsoleTaskState,
taskCounts,
} from "./tasks.ts";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("TaskCreate and TaskUpdate mirror the active TUI task store", () => {
let state = emptyConsoleTaskState();
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject: "first", description: "First detail" }),
);
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject: "second", description: "Second detail" }),
);
state = applyTaskToolCall(
state,
"TaskUpdate",
JSON.stringify({ taskid: 2, status: "inprogress", subject: "working" }),
);
assert(state.tasks.length === 2, "both active tasks should remain visible");
assert(
state.tasks[0].taskid === 1,
"TaskCreate should allocate sequential ids",
);
assert(
state.tasks[1].status === "inprogress",
"TaskUpdate should change status",
);
assert(
state.tasks[1].subject === "working",
"TaskUpdate should change subject",
);
assert(
state.tasks[1].description === "Second detail",
"omitted fields should keep their prior values",
);
const counts = taskCounts(state.tasks);
assert(counts.pending === 1, "one task should be pending");
assert(counts.inprogress === 1, "one task should be in progress");
});
Deno.test("completed and deleted tasks remain in the TUI-style store", () => {
let state = emptyConsoleTaskState();
for (const subject of ["complete", "delete"]) {
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject, description: "detail" }),
);
}
state = applyTaskToolCall(
state,
"TaskUpdate",
JSON.stringify({ taskid: 1, status: "completed" }),
);
state = applyTaskToolCall(
state,
"TaskUpdate",
JSON.stringify({ taskid: 2, status: "deleted" }),
);
assert(
state.tasks.length === 2,
"the full TaskStore should retain inactive tasks",
);
const counts = taskCounts(state.tasks);
assert(counts.completed === 1, "completed tasks should remain counted");
assert(counts.deleted === 1, "deleted tasks should remain counted");
assert(counts.active === 0, "inactive tasks should not be shown as active");
});
Deno.test("session TaskStore snapshot replaces stale state and advances ids", () => {
let state = applyTaskToolCall(
emptyConsoleTaskState(),
"TaskCreate",
JSON.stringify({ subject: "stale", description: "" }),
);
const snapshot = `[Session TaskStore snapshot]
TaskStore: 1 active task(s) (pending: 0, inprogress: 1)
\`\`\`json
{
"tasks": [
{"taskid": 4, "status": "completed", "subject": "old", "description": ""},
{"taskid": 7, "status": "inprogress", "subject": "restored", "description": "detail"}
]
}
\`\`\`
`;
state = applyTaskSnapshotText(state, snapshot);
assert(
state.tasks.length === 2,
"snapshot should restore the full TaskStore",
);
assert(state.tasks[0].taskid === 4, "completed tasks should remain restored");
assert(state.tasks[1].taskid === 7, "active tasks should remain restored");
assert(state.nextTaskId === 8, "next id should follow the highest task id");
state = applyTaskToolCall(
state,
"TaskCreate",
JSON.stringify({ subject: "next", description: "" }),
);
assert(state.tasks[2].taskid === 8, "new task should use the advanced id");
});
Deno.test("malformed task events are resilient no-ops", () => {
const state = emptyConsoleTaskState();
assert(
applyTaskToolCall(state, "TaskCreate", '{"subject":1}') === state,
"invalid TaskCreate should be ignored",
);
assert(
applyTaskToolCall(state, "TaskUpdate", "not json") === state,
"invalid TaskUpdate should be ignored",
);
assert(
applyTaskSnapshotText(state, "ordinary system message") === state,
"unrelated system messages should be ignored",
);
});
@@ -0,0 +1,228 @@
export type ConsoleTaskStatus =
| "pending"
| "inprogress"
| "completed"
| "deleted";
type SnapshotTask = ConsoleTask;
export type ConsoleTask = {
taskid: number;
status: ConsoleTaskStatus;
subject: string;
description: string;
};
export type ConsoleTaskState = {
tasks: ConsoleTask[];
nextTaskId: number;
};
type TaskUpdate = {
taskid: number;
status?: ConsoleTaskStatus;
subject?: string;
description?: string;
};
export function emptyConsoleTaskState(): ConsoleTaskState {
return { tasks: [], nextTaskId: 1 };
}
export function applyTaskToolCall(
state: ConsoleTaskState,
name: string,
argumentsJson: string,
): ConsoleTaskState {
const argumentsValue = parseRecord(argumentsJson);
if (!argumentsValue) return state;
if (name === "TaskCreate") {
const subject = stringField(argumentsValue, "subject");
const description = stringField(argumentsValue, "description");
if (subject === undefined || description === undefined) return state;
return {
tasks: [
...state.tasks,
{
taskid: state.nextTaskId,
status: "pending",
subject,
description,
},
],
nextTaskId: state.nextTaskId + 1,
};
}
if (name !== "TaskUpdate") return state;
const update = taskUpdate(argumentsValue);
if (!update) return state;
const index = state.tasks.findIndex((task) => task.taskid === update.taskid);
if (index < 0) return state;
const current = state.tasks[index];
const status = update.status ?? current.status;
const tasks = [...state.tasks];
tasks[index] = {
taskid: current.taskid,
status,
subject: update.subject ?? current.subject,
description: update.description ?? current.description,
};
return { tasks, nextTaskId: state.nextTaskId };
}
export function applyTaskSnapshotText(
state: ConsoleTaskState,
text: string,
): ConsoleTaskState {
const tasks = parseTaskSnapshotText(text);
if (!tasks) return state;
return {
tasks,
nextTaskId: Math.max(1, ...tasks.map((task) => task.taskid + 1)),
};
}
export function parseTaskSnapshotText(text: string): ConsoleTask[] | null {
if (!text.startsWith("[Session TaskStore snapshot]")) return null;
const startMarker = "```json\n";
const start = text.indexOf(startMarker);
if (start < 0) return null;
const jsonStart = start + startMarker.length;
const end = text.indexOf("\n```", jsonStart);
if (end < 0) return null;
let value: unknown;
try {
value = JSON.parse(text.slice(jsonStart, end));
} catch {
return null;
}
if (!isRecord(value) || !Array.isArray(value.tasks)) return null;
const tasks: ConsoleTask[] = [];
for (const candidate of value.tasks) {
const task = taskEntry(candidate);
if (!task) return null;
tasks.push(task);
}
return tasks;
}
export function taskCounts(tasks: ConsoleTask[]): {
pending: number;
inprogress: number;
completed: number;
deleted: number;
active: number;
total: number;
} {
let pending = 0;
let inprogress = 0;
let completed = 0;
let deleted = 0;
for (const task of tasks) {
switch (task.status) {
case "pending":
pending += 1;
break;
case "inprogress":
inprogress += 1;
break;
case "completed":
completed += 1;
break;
case "deleted":
deleted += 1;
break;
}
}
return {
pending,
inprogress,
completed,
deleted,
active: pending + inprogress,
total: tasks.length,
};
}
function taskEntry(value: unknown): SnapshotTask | null {
if (!isRecord(value)) return null;
const taskid = integerField(value, "taskid");
const status = statusField(value, "status");
const subject = stringField(value, "subject");
const description = stringField(value, "description");
if (
taskid === undefined ||
status === undefined ||
subject === undefined ||
description === undefined
) return null;
return { taskid, status, subject, description };
}
function taskUpdate(value: Record<string, unknown>): TaskUpdate | null {
const taskid = integerField(value, "taskid");
if (taskid === undefined) return null;
const statusValue = value.status;
const status = statusValue === undefined
? undefined
: statusField(value, "status");
if (statusValue !== undefined && status === undefined) return null;
const subjectValue = value.subject;
const subject = subjectValue === undefined
? undefined
: stringField(value, "subject");
if (subjectValue !== undefined && subject === undefined) return null;
const descriptionValue = value.description;
const description = descriptionValue === undefined
? undefined
: stringField(value, "description");
if (descriptionValue !== undefined && description === undefined) return null;
return { taskid, status, subject, description };
}
function parseRecord(value: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(value);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringField(
value: Record<string, unknown>,
key: string,
): string | undefined {
return typeof value[key] === "string" ? value[key] : undefined;
}
function integerField(
value: Record<string, unknown>,
key: string,
): number | undefined {
const field = value[key];
return typeof field === "number" && Number.isSafeInteger(field) && field >= 0
? field
: undefined;
}
function statusField(
value: Record<string, unknown>,
key: string,
): ConsoleTaskStatus | undefined {
const field = value[key];
return field === "pending" ||
field === "inprogress" ||
field === "completed" ||
field === "deleted"
? field
: undefined;
}
@@ -775,3 +775,41 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker", "A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
); );
}); });
Deno.test("Web Console renders the client-projected Worker task store", 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 tasksModel = await Deno.readTextFile(
new URL("./tasks.ts", import.meta.url),
);
assert(
consolePage.includes("ConsoleTasks") &&
consolePage.includes("consoleProjection.tasks") &&
consolePage.includes("taskPaneOpen"),
"Console should expose the projected task store through its existing client model",
);
assert(
tasksComponent.includes("[ ]") &&
tasksComponent.includes("[~]") &&
tasksComponent.includes("[x]") &&
tasksComponent.includes("[-]") &&
tasksComponent.includes("task(s) — pending:") &&
tasksComponent.includes("task.description"),
"Tasks UI should mirror the TUI status marks, summary, and descriptions",
);
assert(
tasksModel.includes('name === "TaskCreate"') &&
tasksModel.includes('name !== "TaskUpdate"') &&
tasksModel.includes("[Session TaskStore snapshot]") &&
!consolePage.includes("fetchTasks"),
"Task projection should replay the protocol client-side without adding a task API",
);
});
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { tick, untrack } from "svelte"; import { tick, untrack } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte"; import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte"; import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace/console/chat-submit"; import { chatSubmit } from "$lib/workspace/console/chat-submit";
import { import {
@@ -114,6 +115,7 @@
} | null = null; } | null = null;
let streamDiagnostics = $state<Diagnostic[]>([]); let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false); let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
let timelineOpen = $state(false); let timelineOpen = $state(false);
let consoleBodyElement: HTMLElement | null = null; let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null; let composerTextareaElement: HTMLTextAreaElement | null = null;
@@ -148,6 +150,7 @@
const consoleTarget = $derived({ workspaceId, runtimeId, workerId }); const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
const lines = $derived(consoleProjection.lines); const lines = $derived(consoleProjection.lines);
const tasks = $derived(consoleProjection.tasks);
const timelineLayout = $derived( const timelineLayout = $derived(
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll), buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
); );
@@ -1086,6 +1089,7 @@
const targetWorker = data.worker; const targetWorker = data.worker;
const targetWorkerError = data.workerError; const targetWorkerError = data.workerError;
resetObservedEvents(); resetObservedEvents();
taskPaneOpen = false;
worker = targetWorker; worker = targetWorker;
workerError = targetWorkerError; workerError = targetWorkerError;
liveWorkerState = targetWorker?.state ?? null; liveWorkerState = targetWorker?.state ?? null;
@@ -1155,11 +1159,25 @@
> >
Rewind Rewind
</button> </button>
<button
type="button"
class="secondary-button"
aria-expanded={taskPaneOpen}
onclick={() => {
taskPaneOpen = !taskPaneOpen;
if (taskPaneOpen) workerDetailsOpen = false;
}}
>
Tasks{tasks.length > 0 ? ` ${tasks.length}` : ""}
</button>
<button <button
type="button" type="button"
class="secondary-button" class="secondary-button"
aria-expanded={workerDetailsOpen} aria-expanded={workerDetailsOpen}
onclick={() => (workerDetailsOpen = !workerDetailsOpen)} onclick={() => {
workerDetailsOpen = !workerDetailsOpen;
if (workerDetailsOpen) taskPaneOpen = false;
}}
> >
Details Details
</button> </button>
@@ -1192,7 +1210,8 @@
</section> </section>
{/if} {/if}
<section class:timeline-open={timelineOpen} class="console-body"> <div class:with-task-pane={taskPaneOpen} class="console-history">
<section class:timeline-open={timelineOpen} class="console-body">
<div class="console-timeline-spacer" aria-hidden="true"></div> <div class="console-timeline-spacer" aria-hidden="true"></div>
<div class="timeline-fold-cell"> <div class="timeline-fold-cell">
<button <button
@@ -1227,15 +1246,20 @@
</article> </article>
</div> </div>
<ConsoleTimeline <ConsoleTimeline
marks={timelineMarks} marks={timelineMarks}
thumbStyle={timelineThumb} thumbStyle={timelineThumb}
axisStyle={timelineAxisStyle} axisStyle={timelineAxisStyle}
expanded={timelineOpen} expanded={timelineOpen}
onRailPointerDown={handleTimelineRailPointerDown} onRailPointerDown={handleTimelineRailPointerDown}
onMarkClick={jumpToTimelineMark} onMarkClick={jumpToTimelineMark}
/> />
</section> </section>
{#if taskPaneOpen}
<ConsoleTasks {tasks} mode="pane" />
{/if}
</div>
{#if workerDetailsOpen} {#if workerDetailsOpen}
<aside class="console-side-panel" aria-label="Worker detail"> <aside class="console-side-panel" aria-label="Worker detail">
@@ -1320,6 +1344,8 @@
</aside> </aside>
{/if} {/if}
<ConsoleTasks {tasks} mode="mini" />
<form class="console-composer card" onsubmit={sendMessage}> <form class="console-composer card" onsubmit={sendMessage}>
<div class="composer-input-shell"> <div class="composer-input-shell">
<textarea <textarea
@@ -1406,7 +1432,7 @@
overflow: hidden; overflow: hidden;
} }
.worker-console-shell > .console-body { .worker-console-shell > .console-history {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -1478,6 +1504,17 @@
color: var(--text-muted); color: var(--text-muted);
} }
.console-history {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(0, 1fr);
}
.console-history.with-task-pane {
grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
}
.console-body { .console-body {
--console-timeline-width: 12rem; --console-timeline-width: 12rem;
--console-timeline-fold-width: 2.25rem; --console-timeline-fold-width: 2.25rem;
@@ -1734,6 +1771,10 @@
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.console-history.with-task-pane {
grid-template-columns: minmax(0, 1fr);
}
.console-header { .console-header {
flex-direction: column; flex-direction: column;
} }