From c3fed591095244223e6da7c84ac7f1a2e4cf8cb7 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 27 Jun 2026 03:06:10 +0900 Subject: [PATCH] feat: worker attach workspace console --- web/workspace/deno.json | 2 + web/workspace/src/app.css | 224 +++++++ .../src/lib/workspace-console/model.test.ts | 181 ++++++ .../src/lib/workspace-console/model.ts | 598 ++++++++++++++++++ .../worker-console.ui.test.ts | 78 +++ .../lib/workspace-pages/WorkspacePage.svelte | 3 + .../CompanionNavSection.svelte | 20 - .../WorkersNavSection.svelte | 22 +- .../workspace-sidebar/WorkspaceSidebar.svelte | 4 +- .../src/lib/workspace-sidebar/types.ts | 53 +- web/workspace/src/routes/console/+page.svelte | 281 -------- .../workers/[workerId]/console/+page.svelte | 419 ++++++++++++ .../workers/[workerId]/console/+page.ts | 8 + web/workspace/tsconfig.json | 1 + 14 files changed, 1582 insertions(+), 312 deletions(-) create mode 100644 web/workspace/src/lib/workspace-console/model.test.ts create mode 100644 web/workspace/src/lib/workspace-console/model.ts create mode 100644 web/workspace/src/lib/workspace-console/worker-console.ui.test.ts delete mode 100644 web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte delete mode 100644 web/workspace/src/routes/console/+page.svelte create mode 100644 web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte create mode 100644 web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index aaa013ca..b9bfb471 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,10 +6,12 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --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 src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, "imports": { + "$lib/": "./src/lib/", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index 45724bf7..90f0ee69 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -677,3 +677,227 @@ cursor: not-allowed; opacity: 0.55; } + +.nav-link { + display: grid; + gap: var(--space-1); + color: inherit; + text-decoration: none; +} + +.nav-item.active, +.nav-item:has(.nav-link[aria-current='page']) { + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.inline-link, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + border-radius: 999px; + border: 1px solid var(--border); + padding: 0.4rem 0.75rem; + background: #ffffff; + color: #1d4ed8; + font-size: 0.85rem; + font-weight: 800; + text-decoration: none; + cursor: pointer; +} + +.secondary-button:hover, +.inline-link:hover { + border-color: #bfdbfe; + background: #eff6ff; +} + +.worker-console-shell { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.console-status-pill { + min-width: 14rem; + padding: 0.75rem 0.9rem; + border-radius: 16px; + background: #ecfeff; + border: 1px solid #a5f3fc; + color: #0f766e; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.76rem; + text-align: right; +} + +.console-status-pill.warn { + background: #fff7ed; + border-color: #fed7aa; + color: #c2410c; +} + +.console-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 26rem); + gap: var(--space-4); + min-height: 0; +} + +.transcript-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.worker-transcript { + display: grid; + gap: var(--space-3); + max-height: min(68dvh, 50rem); + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.worker-transcript li { + display: grid; + gap: var(--space-2); + border: 1px solid #e2e8f0; + border-left: 4px solid #cbd5e1; + border-radius: 14px; + padding: 0.75rem 0.85rem; + background: #ffffff; +} + +.worker-transcript li.user { + border-left-color: #2563eb; + background: #eff6ff; +} + +.worker-transcript li.assistant { + border-left-color: #10b981; + background: #f0fdf4; +} + +.worker-transcript li.error-line { + border-left-color: #dc2626; + background: #fef2f2; +} + +.message-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + color: #0f172a; + font-weight: 800; +} + +.message-heading small { + margin: 0; + color: #64748b; + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; +} + +.worker-transcript pre { + max-width: 100%; + margin: 0; + overflow-x: auto; + white-space: pre-wrap; + color: #1f2937; +} + +.message-detail, +.metadata-details { + color: #475569; + font-size: 0.84rem; +} + +.message-detail summary, +.metadata-details summary { + cursor: pointer; + font-weight: 800; +} + +.console-side-card { + align-content: start; + display: grid; + gap: var(--space-4); + min-width: 0; +} + +.console-side-card dl, +.console-side-card ul { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.console-side-card dt { + color: #64748b; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.console-side-card dd { + margin: 0; + color: #0f172a; + font-weight: 700; +} + +.degrade-note { + border: 1px solid #fde68a; + border-radius: 12px; + padding: 0.65rem 0.8rem; + background: #fffbeb; +} + +.console-composer { + display: grid; + gap: var(--space-3); +} + +.console-composer label { + color: #0f172a; + font-weight: 800; +} + +.console-composer textarea { + width: 100%; + min-height: 7rem; + resize: vertical; + border: 1px solid #cbd5e1; + border-radius: 14px; + padding: 0.85rem 1rem; + font: inherit; + color: #0f172a; +} + +.console-composer textarea:disabled { + background: #f8fafc; + color: #64748b; +} + +@media (max-width: 960px) { + .console-grid { + grid-template-columns: 1fr; + } + + .console-header { + flex-direction: column; + } + + .console-status-pill { + width: 100%; + text-align: left; + } +} diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts new file mode 100644 index 00000000..0a7dbd88 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -0,0 +1,181 @@ +import type { Event } from "$lib/generated/protocol"; +import { projectConsole, segmentsToText, workerConsoleHref } from "./model.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { + assert( + workerConsoleHref({ + runtime_id: "local runtime", + worker_id: "worker/one", + }) === + "/runtimes/local%20runtime/workers/worker%2Fone/console", + "href should contain encoded runtime_id and worker_id segments", + ); +}); + +Deno.test("segmentsToText preserves protocol segment semantics", () => { + const text = segmentsToText([ + { kind: "text", content: "hello" }, + { kind: "file_ref", path: "/tmp/example.md" }, + { kind: "knowledge_ref", slug: "design-note" }, + { kind: "workflow_invoke", slug: "ticket-review" }, + ]); + + assert(text.includes("hello"), "text segment should render content"); + assert( + text.includes("@file /tmp/example.md"), + "file ref should render as a file reference", + ); + assert( + text.includes("@knowledge design-note"), + "knowledge ref should render as a knowledge reference", + ); + assert( + text.includes("/ticket-review"), + "workflow invocation should render as slash command", + ); +}); + +Deno.test("projectConsole keeps transcript and protocol-derived event rows distinct", () => { + const projection = projectConsole( + [ + { + sequence: 1, + role: "user", + content: "transcript input", + event_id: 10, + }, + ], + [ + { + cursor: "11", + event: { + event: "text_delta", + data: { text: "stream" }, + } satisfies Event, + }, + { + cursor: "12", + event: { + event: "thinking_done", + data: { text: "reasoning" }, + } satisfies Event, + }, + { + cursor: "13", + event: { + event: "tool_result", + data: { + id: "tool-1", + summary: "read file", + output: "content", + is_error: false, + }, + } satisfies Event, + }, + { + cursor: "14", + event: { + event: "usage", + data: { input_tokens: 12, output_tokens: 5 }, + } satisfies Event, + }, + { + cursor: "15", + event: { + event: "error", + data: { code: "invalid_request", message: "bad frame" }, + } satisfies Event, + }, + ], + ); + + assert( + projection.lines.some((line) => + line.source === "transcript" && line.kind === "user" + ), + "transcript user row expected", + ); + assert( + projection.lines.some((line) => + line.source === "event" && line.kind === "assistant" + ), + "assistant event row expected", + ); + assert( + projection.lines.some((line) => line.kind === "thinking"), + "thinking event row expected", + ); + assert( + projection.lines.some((line) => line.kind === "tool"), + "tool event row expected", + ); + assert( + projection.lines.some((line) => line.kind === "usage"), + "usage event row expected", + ); + assert( + projection.lines.some((line) => line.kind === "error" && line.error), + "error event row expected", + ); + assert( + projection.usage === "input 12 · output 5 · cache unknown", + "usage summary should be retained", + ); +}); + +Deno.test("projectConsole displays snapshot and in-flight state", () => { + const projection = projectConsole([], [ + { + cursor: "20", + event: { + event: "snapshot", + data: { + entries: [{ role: "user" }], + greeting: { + worker_name: "Worker", + cwd: "/repo", + provider: "provider", + model: "model", + scope_summary: "bounded", + tools: ["Read"], + context_window: 100, + context_tokens: 20, + }, + status: "running", + in_flight: { + blocks: [ + { kind: "text", text: "unfinished answer", finished: false }, + { + kind: "tool_call", + id: "call-1", + name: "Read", + args: "{}", + state: "streaming_args", + }, + ], + }, + }, + } satisfies Event, + }, + ]); + + assert(projection.status === "running", "snapshot should update status"); + assert( + projection.lines.some((line) => line.kind === "snapshot"), + "snapshot row expected", + ); + assert( + projection.lines.filter((line) => line.kind === "in_flight").length === 2, + "in-flight rows expected", + ); +}); diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts new file mode 100644 index 00000000..526e1707 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -0,0 +1,598 @@ +import type { + Event as ProtocolEvent, + InFlightBlock, + Segment, +} from "$lib/generated/protocol"; +import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types"; + +export type ConsoleLineKind = + | "user" + | "assistant" + | "thinking" + | "tool" + | "status" + | "error" + | "usage" + | "snapshot" + | "in_flight" + | "system"; + +export type ConsoleLine = { + id: string; + kind: ConsoleLineKind; + title: string; + body: string; + detail?: string; + cursor?: string | null; + source: "transcript" | "event"; + streaming?: boolean; + error?: boolean; +}; + +export type ConsoleProjection = { + lines: ConsoleLine[]; + status: string | null; + usage: string | null; + lastCursor: string | null; +}; + +export type WorkerTarget = { + runtime_id: string; + worker_id: string; +}; + +export function workerConsoleHref(target: WorkerTarget): string { + return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${ + encodeURIComponent( + target.worker_id, + ) + }/console`; +} + +export function workerConsolePath(runtimeId: string, workerId: string): string { + return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }); +} + +export function transcriptLines(items: WorkerTranscriptItem[]): ConsoleLine[] { + return items.map((item) => ({ + id: `transcript-${item.event_id}-${item.sequence}`, + kind: transcriptRoleKind(item.role), + title: `${item.role} · transcript #${item.sequence}`, + body: item.content, + detail: `event ${item.event_id}`, + source: "transcript", + })); +} + +export function projectConsole( + transcript: WorkerTranscriptItem[], + events: Array<{ cursor: string; event: ProtocolEvent }> = [], +): ConsoleProjection { + return events.reduce(applyProtocolEvent, { + lines: transcriptLines(transcript), + status: null, + usage: null, + lastCursor: null, + }); +} + +export function applyProtocolEvent( + projection: ConsoleProjection, + envelope: { cursor: string; event: ProtocolEvent }, +): ConsoleProjection { + const next: ConsoleProjection = { + lines: [...projection.lines], + status: projection.status, + usage: projection.usage, + lastCursor: envelope.cursor, + }; + const event = envelope.event; + + switch (event.event) { + case "user_message": + next.lines.push( + line( + envelope.cursor, + "user", + "user message", + segmentsToText(event.data.segments), + ), + ); + break; + case "system_item": + next.lines.push( + line( + envelope.cursor, + "system", + "system item", + jsonPreview(event.data.item), + ), + ); + break; + case "text_delta": + appendStreaming( + next, + envelope.cursor, + "assistant", + "assistant streaming", + event.data.text, + ); + break; + case "text_done": + finalizeStreaming( + next, + "assistant", + envelope.cursor, + "assistant", + event.data.text, + ); + break; + case "thinking_start": + next.lines.push( + line(envelope.cursor, "thinking", "thinking", "", undefined, true), + ); + break; + case "thinking_delta": + appendStreaming( + next, + envelope.cursor, + "thinking", + "thinking", + event.data.text, + ); + break; + case "thinking_done": + finalizeStreaming( + next, + "thinking", + envelope.cursor, + "thinking", + event.data.text, + ); + break; + case "tool_call_start": + next.lines.push( + line( + envelope.cursor, + "tool", + `tool call · ${event.data.name}`, + `id: ${event.data.id}`, + undefined, + true, + ), + ); + break; + case "tool_call_args_delta": + appendToolArgs(next, envelope.cursor, event.data.id, event.data.json); + break; + case "tool_call_done": + next.lines.push( + line( + envelope.cursor, + "tool", + `tool call done · ${event.data.name}`, + event.data.arguments, + `id: ${event.data.id}`, + ), + ); + break; + case "tool_result": + next.lines.push( + line( + envelope.cursor, + "tool", + event.data.is_error ? "tool result error" : "tool result", + event.data.output ?? event.data.summary, + `id: ${event.data.id} · ${event.data.summary}`, + false, + event.data.is_error, + ), + ); + break; + case "usage": + next.usage = usageText(event.data); + next.lines.push(line(envelope.cursor, "usage", "usage", next.usage)); + break; + case "error": + next.lines.push( + line( + envelope.cursor, + "error", + `error · ${event.data.code}`, + event.data.message, + undefined, + false, + true, + ), + ); + break; + case "snapshot": + next.status = event.data.status; + next.lines.push( + line( + envelope.cursor, + "snapshot", + `snapshot · ${event.data.status}`, + `${event.data.entries.length} entries · ${event.data.greeting.provider} / ${event.data.greeting.model}`, + `${event.data.greeting.worker_name} · context ${event.data.greeting.context_tokens}/${event.data.greeting.context_window}`, + ), + ); + for (const block of event.data.in_flight?.blocks ?? []) { + next.lines.push(inFlightLine(envelope.cursor, block)); + } + break; + case "status": + next.status = event.data.status; + next.lines.push( + line(envelope.cursor, "status", "status", event.data.status), + ); + break; + case "invoke_start": + next.lines.push( + line(envelope.cursor, "status", "invoke start", event.data.kind), + ); + break; + case "turn_start": + next.lines.push( + line( + envelope.cursor, + "status", + "turn start", + `turn ${event.data.turn}`, + ), + ); + break; + case "turn_end": + next.lines.push( + line( + envelope.cursor, + "status", + "turn end", + `turn ${event.data.turn} · ${event.data.result}`, + ), + ); + break; + case "llm_call_start": + next.lines.push( + line( + envelope.cursor, + "status", + "llm call start", + `call ${event.data.llm_call}`, + ), + ); + break; + case "llm_call_end": + next.lines.push( + line( + envelope.cursor, + "status", + "llm call end", + `call ${event.data.llm_call}`, + ), + ); + break; + case "llm_retry": + next.lines.push( + line( + envelope.cursor, + "status", + "llm retry", + `${event.data.error} · attempt ${event.data.failed_attempt}/${event.data.max_attempts}`, + ), + ); + break; + case "llm_continuation": + next.lines.push( + line( + envelope.cursor, + "status", + "llm continuation", + `${event.data.reason} · attempt ${event.data.attempt}/${event.data.max_attempts}`, + ), + ); + break; + case "run_end": + next.lines.push( + line(envelope.cursor, "status", "run end", event.data.result), + ); + break; + case "alert": + next.lines.push( + line( + envelope.cursor, + "status", + `alert · ${event.data.level}`, + event.data.message, + ), + ); + break; + case "memory_worker": + next.lines.push( + line(envelope.cursor, "status", "memory worker", event.data.message), + ); + break; + case "segment_rotated": + next.lines.push( + line( + envelope.cursor, + "status", + "segment rotated", + jsonPreview(event.data.entry), + ), + ); + break; + case "completions": + next.lines.push( + line( + envelope.cursor, + "status", + "completions", + `${event.data.kind} · ${event.data.entries.length} entries`, + ), + ); + break; + case "rewind_targets": + next.lines.push( + line( + envelope.cursor, + "status", + "rewind targets", + `${event.data.targets.length} targets · head ${event.data.head_entries}`, + ), + ); + break; + case "rewind_applied": + next.lines.push( + line( + envelope.cursor, + "status", + "rewind applied", + `${event.data.summary.discarded_entries} discarded · ${event.data.summary.truncated_to_entries} retained`, + ), + ); + break; + case "workers_listed": + next.lines.push( + line( + envelope.cursor, + "status", + "workers listed", + jsonPreview(event.data.workers), + ), + ); + break; + case "worker_restored": + next.lines.push( + line( + envelope.cursor, + "status", + "worker restored", + jsonPreview(event.data.result), + ), + ); + break; + case "peer_registered": + next.lines.push( + line( + envelope.cursor, + "status", + "peer registered", + jsonPreview(event.data.result), + ), + ); + break; + case "compact_start": + next.lines.push( + line(envelope.cursor, "status", "compact start", "compaction started"), + ); + break; + case "compact_done": + next.lines.push( + line( + envelope.cursor, + "status", + "compact done", + event.data.new_segment_id, + ), + ); + break; + case "compact_failed": + next.lines.push( + line( + envelope.cursor, + "error", + "compact failed", + event.data.error, + undefined, + false, + true, + ), + ); + break; + case "shutdown": + next.status = "shutdown"; + next.lines.push( + line(envelope.cursor, "status", "shutdown", "worker shut down"), + ); + break; + } + + return next; +} + +export function segmentsToText(segments: Segment[]): string { + return segments + .map((segment) => { + switch (segment.kind) { + case "text": + return segment.content; + case "paste": + return segment.content || + `[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`; + case "file_ref": + return `@file ${segment.path}`; + case "knowledge_ref": + return `@knowledge ${segment.slug}`; + case "workflow_invoke": + return `/${segment.slug}`; + case "unknown": + return "[unknown segment]"; + } + }) + .join("\n"); +} + +function transcriptRoleKind(role: string): ConsoleLineKind { + if (role === "user" || role === "assistant" || role === "system") { + return role; + } + return "system"; +} + +function line( + cursor: string, + kind: ConsoleLineKind, + title: string, + body: string, + detail?: string, + streaming = false, + error = false, +): ConsoleLine { + return { + id: `event-${cursor}-${kind}-${slugify(title)}-${body.length}`, + kind, + title, + body, + detail, + cursor, + source: "event", + streaming, + error, + }; +} + +function appendStreaming( + projection: ConsoleProjection, + cursor: string, + kind: "assistant" | "thinking", + title: string, + delta: string, +): void { + const existing = [...projection.lines].reverse().find((item) => + item.kind === kind && item.streaming + ); + if (existing) { + existing.body += delta; + existing.cursor = cursor; + return; + } + projection.lines.push(line(cursor, kind, title, delta, undefined, true)); +} + +function finalizeStreaming( + projection: ConsoleProjection, + kind: "assistant" | "thinking", + cursor: string, + title: string, + body: string, +): void { + const existing = [...projection.lines].reverse().find((item) => + item.kind === kind && item.streaming + ); + if (existing) { + existing.body = body || existing.body; + existing.streaming = false; + existing.title = title; + existing.cursor = cursor; + return; + } + projection.lines.push(line(cursor, kind, title, body)); +} + +function appendToolArgs( + projection: ConsoleProjection, + cursor: string, + id: string, + delta: string, +): void { + const existing = [...projection.lines] + .reverse() + .find((item) => + item.kind === "tool" && item.streaming && item.body.includes(`id: ${id}`) + ); + if (existing) { + existing.body += delta; + existing.cursor = cursor; + return; + } + projection.lines.push( + line( + cursor, + "tool", + "tool call args", + `id: ${id}\n${delta}`, + undefined, + true, + ), + ); +} + +function usageText( + data: { + input_tokens: number | null; + output_tokens: number | null; + cache_read_input_tokens?: number | null; + }, +): string { + return `input ${data.input_tokens ?? "unknown"} · output ${ + data.output_tokens ?? "unknown" + } · cache ${data.cache_read_input_tokens ?? "unknown"}`; +} + +function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine { + switch (block.kind) { + case "text": + return line( + cursor, + "in_flight", + "in-flight assistant text", + block.text, + undefined, + !block.finished, + ); + case "thinking": + return line( + cursor, + "in_flight", + "in-flight thinking", + block.text, + undefined, + !block.finished, + ); + case "tool_call": + return line( + cursor, + "in_flight", + `in-flight tool · ${block.name}`, + block.args, + `${block.id} · ${block.state ?? "pending"}`, + block.state !== "done", + ); + } +} + +function slugify(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace( + /^-|-$/g, + "", + ) || "event"; +} + +function jsonPreview(value: unknown): string { + try { + return JSON.stringify(value, null, 2) ?? "null"; + } catch { + return String(value); + } +} 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 new file mode 100644 index 00000000..d74dd520 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/worker-console.ui.test.ts @@ -0,0 +1,78 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; + readTextFile(path: string | URL): Promise; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => { + const workspacePage = await Deno.readTextFile( + new URL("../workspace-pages/WorkspacePage.svelte", import.meta.url), + ); + const workersNav = await Deno.readTextFile( + new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url), + ); + const sidebar = await Deno.readTextFile( + new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url), + ); + + assert( + workspacePage.includes("workerConsoleHref(worker)") && + workspacePage.includes("Open Console"), + "top Worker list should expose an attach action per Worker", + ); + assert( + workersNav.includes("workerConsoleHref(worker)") && + workersNav.includes("aria-current"), + "Workers sidebar rows should link to the Worker target Console route", + ); + assert( + !sidebar.includes("CompanionNavSection") && + sidebar.includes("WorkersNavSection"), + "standalone Companion/Console navigation should not remain canonical", + ); +}); + +Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", + import.meta.url, + ), + ); + const routeLoad = await Deno.readTextFile( + new URL( + "./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts", + import.meta.url, + ), + ); + + assert( + routeLoad.includes("runtimeId") && routeLoad.includes("workerId"), + "route load should expose both target ids", + ); + assert( + consolePage.includes( + "/api/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}", + ), + "Worker detail should use the backend Worker detail API", + ); + assert( + consolePage.includes("/transcript?limit=200") && + consolePage.includes("/events/ws") && consolePage.includes("/input"), + "Console should use bounded transcript, observation WS, and input APIs", + ); + assert( + !consolePage.includes("/api/companion"), + "Console page must not use Companion-specific APIs", + ); + assert( + consolePage.includes("streaming observation is not available") || + consolePage.includes("Streaming observation is not available"), + "Console should show an explicit non-streaming degradation path", + ); +}); diff --git a/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte b/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte index b628195e..6618ff12 100644 --- a/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte +++ b/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte @@ -1,5 +1,6 @@ - - diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index 8cd50e3f..ca019596 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -1,8 +1,15 @@ - - - Companion Console · Yoi Workspace - - - -
- - -
-
-
-

Backend-internal Companion

-

Companion Console

-

- Browser traffic stays behind Workspace API projections. No Worker socket, session path, - runtime credential, or local session file is exposed to the frontend. -

-
-
- {operationState} - {#if status?.worker} - {status.worker.label} - {:else} - worker pending - {/if} -
-
- - {#if status?.transport} -
-
-
Transport
-
{status.transport.kind}
-
-
-
Completion
-
{status.transport.completion}
-
-

{status.transport.limitation}

-
- {/if} - - {#if error || timeoutNotice || diagnostics.length > 0} -
- {#if timeoutNotice} -

{timeoutNotice}

- {/if} - {#if error} -

{error}

- {/if} - {#each diagnostics as diagnostic} -

{diagnostic.code}: {diagnostic.message}

- {/each} -
- {/if} - -
-
-

Transcript

- {transcript?.total_items ?? 0} items -
- {#if messages.length === 0} -

No Companion messages yet. Send a message to exercise the backend boundary.

- {:else} -
    - {#each messages as message (message.sequence)} -
  1. -
    - {message.role} - {message.status} - -
    -

    {message.content}

    -
  2. - {/each} -
- {/if} -
- -
- - -
- {draft.trim().length}/8000 - - - -
-
-
-
diff --git a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte new file mode 100644 index 00000000..02c611c8 --- /dev/null +++ b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -0,0 +1,419 @@ + + + + Worker Console · Yoi Workspace + + + +
+ + +
+
+
+

Worker attach Console

+

{worker?.label ?? workerId}

+

+ Target authority is runtime_id + worker_id. Browser traffic uses Workspace Backend Worker APIs only; + Runtime endpoints, credentials, socket paths, and session paths are not exposed. +

+
+
+ {worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState} +
+
+ +
+
+
+
+

Transcript and protocol events

+ {#if projection.status || projection.usage} +

+ {#if projection.status}status: {projection.status}{/if} + {#if projection.status && projection.usage} · {/if} + {#if projection.usage}usage: {projection.usage}{/if} +

+ {/if} +
+ +
+ + {#if workerError} +

{workerError}

+ {/if} + {#if transcriptError} +

{transcriptError}

+ {/if} + {#if transcriptOnly} +

{transcriptOnly}

+ {/if} + + {#if lines.length === 0} +

No transcript items or observation events are available for this Worker yet.

+ {:else} +
    + {#each lines as item} +
  1. +
    + {item.title} + {item.source}{item.streaming ? ' · streaming' : ''} +
    +
    {item.body || '—'}
    + {#if item.detail || item.cursor} +
    + metadata + {#if item.detail}

    {item.detail}

    {/if} + {#if item.cursor}{item.cursor}{/if} +
    + {/if} +
  2. + {/each} +
+ {/if} +
+ + +
+ +
+ + +
+ + {#if sendError}

{sendError}

{/if} +
+
+
+
diff --git a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts new file mode 100644 index 00000000..4da7948a --- /dev/null +++ b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts @@ -0,0 +1,8 @@ +export function load( + { params }: { params: { runtimeId: string; workerId: string } }, +) { + return { + runtimeId: params.runtimeId, + workerId: params.workerId, + }; +} diff --git a/web/workspace/tsconfig.json b/web/workspace/tsconfig.json index 43447105..9d7c4dce 100644 --- a/web/workspace/tsconfig.json +++ b/web/workspace/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, + "allowImportingTsExtensions": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true,