From 93eea244200e514373ea54524d817e0c24accc02 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 14 Aug 2026 12:46:59 +0900 Subject: [PATCH] web: show Worker tasks in Console --- web/workspace/deno.json | 2 +- .../lib/workspace/console/ConsoleTasks.svelte | 183 ++++++++++++++ .../src/lib/workspace/console/model.test.ts | 65 +++++ .../src/lib/workspace/console/model.ts | 91 ++++++- .../src/lib/workspace/console/tasks.test.ts | 134 ++++++++++ .../src/lib/workspace/console/tasks.ts | 228 ++++++++++++++++++ .../console/worker-console.ui.test.ts | 38 +++ .../workers/[workerId]/console/+page.svelte | 65 ++++- 8 files changed, 780 insertions(+), 26 deletions(-) create mode 100644 web/workspace/src/lib/workspace/console/ConsoleTasks.svelte create mode 100644 web/workspace/src/lib/workspace/console/tasks.test.ts create mode 100644 web/workspace/src/lib/workspace/console/tasks.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 07317361..55704d17 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "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", "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", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte b/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte new file mode 100644 index 00000000..a78285d9 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ConsoleTasks.svelte @@ -0,0 +1,183 @@ + + +{#if mode === "mini" && tasks.length > 0} +
+ {#each activeTasks as task (task.taskid)} +
+ + {mark(task.status)} + + {task.subject.split("\n", 1)[0]} +
+ {/each} +
+ {counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted} +
+
+{:else if mode === "pane"} + +{/if} + + diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 8c2487eb..6572c26c 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -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", ); }); + +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); +}); diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 3b1ec47e..289905fc 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -6,6 +6,11 @@ import type { Segment, } from "$lib/generated/protocol"; import { workspaceRoute } from "$lib/workspace/api/http"; +import { + applyTaskSnapshotText, + applyTaskToolCall, + type ConsoleTask, +} from "./tasks.ts"; export type ConsoleLineKind = | "user" @@ -60,6 +65,8 @@ export type ConsoleLine = { export type ConsoleProjection = { lines: ConsoleLine[]; + tasks: ConsoleTask[]; + taskNextId: number; status: string | null; usage: string | null; cwd: string | null; @@ -138,6 +145,8 @@ export type ConsoleEventInput = { export function emptyConsoleProjection(): ConsoleProjection { return { lines: [], + tasks: [], + taskNextId: 1, status: null, usage: null, cwd: null, @@ -189,6 +198,8 @@ export function applyProtocolEvent( ): ConsoleProjection { const next: ConsoleProjection = { lines: [...projection.lines], + tasks: [...projection.tasks], + taskNextId: projection.taskNextId, status: projection.status, usage: projection.usage, cwd: projection.cwd, @@ -209,6 +220,7 @@ export function applyProtocolEvent( break; case "system_item": next.lines.push(systemItemLine(envelope.eventId, event.data.item)); + applyTaskSystemItem(next, event.data.item); break; case "text_delta": appendStreaming( @@ -267,6 +279,7 @@ export function applyProtocolEvent( argsStream: event.data.arguments, state: "running", }); + applyTaskTool(next, event.data.name, event.data.arguments); break; case "tool_result": attachToolResult(next, envelope.eventId, event.data.id, { @@ -291,26 +304,36 @@ export function applyProtocolEvent( ), ); break; - case "snapshot": + case "snapshot": { next.status = event.data.status; next.cwd = event.data.greeting.cwd; - next.lines = snapshotLinesFromEntries( + const snapshot = snapshotProjectionFromEntries( envelope.eventId, event.data.entries, next.cwd, ); + next.lines = snapshot.lines; + next.tasks = snapshot.tasks; + next.taskNextId = snapshot.taskNextId; for (const block of event.data.in_flight?.blocks ?? []) { next.lines.push(inFlightLine(envelope.eventId, block, next.cwd)); } break; + } case "status": next.status = event.data.status; break; - case "segment_rotated": - next.lines = snapshotLinesFromEntries(envelope.eventId, [ - event.data.entry, - ], next.cwd); + case "segment_rotated": { + const segment = snapshotProjectionFromEntries( + envelope.eventId, + [event.data.entry], + next.cwd, + ); + next.lines = segment.lines; + next.tasks = segment.tasks; + next.taskNextId = segment.taskNextId; break; + } case "invoke_start": case "turn_start": case "turn_end": @@ -1111,13 +1134,47 @@ function usageText( } · 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, entries: unknown[], cwd: string | null, -): ConsoleLine[] { +): ConsoleProjection { const projection: ConsoleProjection = { lines: [], + tasks: [], + taskNextId: 1, status: null, usage: null, cwd, @@ -1126,7 +1183,7 @@ function snapshotLinesFromEntries( entries.forEach((entry, index) => applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry) ); - return projection.lines; + return projection; } function applyLogEntry( @@ -1153,6 +1210,7 @@ function applyLogEntry( break; case "system_item": projection.lines.push(systemItemLine(eventId, entry["item"])); + applyTaskSystemItem(projection, entry["item"]); break; case "assistant_item": case "tool_result": @@ -1224,6 +1282,9 @@ function applyLoggedItem( case "assistant": projection.lines.push(line(eventId, "assistant", "assistant", body)); break; + case "system": + applyTaskSnapshot(projection, body); + break; default: break; } @@ -1238,19 +1299,23 @@ function applyLoggedItem( } break; } - case "tool_call": + case "tool_call": { + const name = stringField(item, "name") ?? "Tool"; + const argumentsJson = stringField(item, "arguments") ?? ""; upsertToolCall( projection, eventId, stringField(item, "call_id") ?? eventId, { - name: stringField(item, "name") ?? "Tool", - arguments: stringField(item, "arguments") ?? "", - argsStream: stringField(item, "arguments") ?? "", + name, + arguments: argumentsJson, + argsStream: argumentsJson, state: "running", }, ); + applyTaskTool(projection, name, argumentsJson); break; + } case "tool_result": attachToolResult( projection, diff --git a/web/workspace/src/lib/workspace/console/tasks.test.ts b/web/workspace/src/lib/workspace/console/tasks.test.ts new file mode 100644 index 00000000..084453c8 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/tasks.test.ts @@ -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", + ); +}); diff --git a/web/workspace/src/lib/workspace/console/tasks.ts b/web/workspace/src/lib/workspace/console/tasks.ts new file mode 100644 index 00000000..8a0fc114 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/tasks.ts @@ -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): 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 | null { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringField( + value: Record, + key: string, +): string | undefined { + return typeof value[key] === "string" ? value[key] : undefined; +} + +function integerField( + value: Record, + key: string, +): number | undefined { + const field = value[key]; + return typeof field === "number" && Number.isSafeInteger(field) && field >= 0 + ? field + : undefined; +} + +function statusField( + value: Record, + key: string, +): ConsoleTaskStatus | undefined { + const field = value[key]; + return field === "pending" || + field === "inprogress" || + field === "completed" || + field === "deleted" + ? field + : undefined; +} 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 bab62711..7199b611 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 @@ -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", ); }); + +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", + ); +}); 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 b9ad15cd..ae4def3e 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 @@ -1,6 +1,7 @@