web: show Worker tasks in Console
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
} 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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
+53
-12
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from "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 { chatSubmit } from "$lib/workspace/console/chat-submit";
|
||||
import {
|
||||
@@ -114,6 +115,7 @@
|
||||
} | null = null;
|
||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||
let workerDetailsOpen = $state(false);
|
||||
let taskPaneOpen = $state(false);
|
||||
let timelineOpen = $state(false);
|
||||
let consoleBodyElement: HTMLElement | null = null;
|
||||
let composerTextareaElement: HTMLTextAreaElement | null = null;
|
||||
@@ -148,6 +150,7 @@
|
||||
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
|
||||
|
||||
const lines = $derived(consoleProjection.lines);
|
||||
const tasks = $derived(consoleProjection.tasks);
|
||||
const timelineLayout = $derived(
|
||||
buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll),
|
||||
);
|
||||
@@ -1086,6 +1089,7 @@
|
||||
const targetWorker = data.worker;
|
||||
const targetWorkerError = data.workerError;
|
||||
resetObservedEvents();
|
||||
taskPaneOpen = false;
|
||||
worker = targetWorker;
|
||||
workerError = targetWorkerError;
|
||||
liveWorkerState = targetWorker?.state ?? null;
|
||||
@@ -1155,11 +1159,25 @@
|
||||
>
|
||||
Rewind
|
||||
</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
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
aria-expanded={workerDetailsOpen}
|
||||
onclick={() => (workerDetailsOpen = !workerDetailsOpen)}
|
||||
onclick={() => {
|
||||
workerDetailsOpen = !workerDetailsOpen;
|
||||
if (workerDetailsOpen) taskPaneOpen = false;
|
||||
}}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
@@ -1192,7 +1210,8 @@
|
||||
</section>
|
||||
{/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="timeline-fold-cell">
|
||||
<button
|
||||
@@ -1227,15 +1246,20 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ConsoleTimeline
|
||||
marks={timelineMarks}
|
||||
thumbStyle={timelineThumb}
|
||||
axisStyle={timelineAxisStyle}
|
||||
expanded={timelineOpen}
|
||||
onRailPointerDown={handleTimelineRailPointerDown}
|
||||
onMarkClick={jumpToTimelineMark}
|
||||
/>
|
||||
</section>
|
||||
<ConsoleTimeline
|
||||
marks={timelineMarks}
|
||||
thumbStyle={timelineThumb}
|
||||
axisStyle={timelineAxisStyle}
|
||||
expanded={timelineOpen}
|
||||
onRailPointerDown={handleTimelineRailPointerDown}
|
||||
onMarkClick={jumpToTimelineMark}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#if taskPaneOpen}
|
||||
<ConsoleTasks {tasks} mode="pane" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if workerDetailsOpen}
|
||||
<aside class="console-side-panel" aria-label="Worker detail">
|
||||
@@ -1320,6 +1344,8 @@
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<ConsoleTasks {tasks} mode="mini" />
|
||||
|
||||
<form class="console-composer card" onsubmit={sendMessage}>
|
||||
<div class="composer-input-shell">
|
||||
<textarea
|
||||
@@ -1406,7 +1432,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.worker-console-shell > .console-body {
|
||||
.worker-console-shell > .console-history {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -1478,6 +1504,17 @@
|
||||
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-timeline-width: 12rem;
|
||||
--console-timeline-fold-width: 2.25rem;
|
||||
@@ -1734,6 +1771,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.console-history.with-task-pane {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.console-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user