merge: worker console redesign

# Conflicts:
#	web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte
This commit is contained in:
Keisuke Hirata 2026-06-27 03:26:48 +09:00
commit 62420b7cc4
No known key found for this signature in database
17 changed files with 1956 additions and 317 deletions

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260626-174605-1","ticket_id":"00001KW2GCPYF","kind":"accepted_plan","note":"Dashboard Queue accepted. No relation blockers, no inprogress work, orchestration worktree clean. Previous Runtime/Backend/TUI/WebConsole foundations are on develop/orchestration HEAD.","accepted_plan":{"summary":"Workspace Web Console を Companion 専用 route/sidebar から任意 Worker attach route へ再設計する。Worker list から `runtime_id + worker_id` で開き、Backend transcript/input/observation WS と protocol event rendering を使う。旧 `/console` route/fallback と standalone Console sidebar entry は残さない。","branch":"work/00001KW2GCPYF-worker-console-redesign","worktree":"/home/hare/Projects/yoi/.worktree/00001KW2GCPYF-worker-console-redesign","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `web/workspace` と必要な Backend API support (`crates/workspace-server`)、Cargo/package files の write scope を委譲する。reviewer Worker は read-only で route/navigation authority、runtime_id+worker_id attach、protocol::Event rendering semantics、metadata/diagnostics layout、legacy `/console` removal、Browser credential/path non-leak を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-26T17:46:05Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Workspace Worker Consoleを任意Worker attach前提で再設計する' title: 'Workspace Worker Consoleを任意Worker attach前提で再設計する'
state: 'queued' state: 'done'
created_at: '2026-06-26T17:42:10Z' created_at: '2026-06-26T17:42:10Z'
updated_at: '2026-06-26T17:45:40Z' updated_at: '2026-06-26T18:22:51Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-06-26T17:45:40Z' queued_at: '2026-06-26T17:45:40Z'

View File

@ -30,4 +30,352 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-26T17:46:30Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Dashboard Queue により routing 許可済み。
- Relations はなし、OrchestrationPlan も未設定だった。blocking dependency は見当たらない。
- Current `inprogress` is 0。orchestration worktree is clean and current HEAD already contains Runtime/Backend/WebSocket/WebConsole/TUI foundations.
- Ticket body は navigation/route model、attach routing、UX redesign、protocol rendering model、input/observation、Non-goals、acceptance criteria が具体的。
Evidence checked:
- Ticket body: Workspace top Worker list から任意 Worker Console、`runtime_id + worker_id` authority、Companion を通常 Worker として扱う、standalone Console sidebar entry / old `/console` fallback removal、protocol::Event rendering semantics、degrade path、validation requirements。
- Relations: none。
- Orchestration plan: accepted plan `orch-plan-20260626-174605-1` recorded。
- Workspace state: queued 1 / inprogress 0、orchestration clean、child spawned count 0。
IntentPacket:
Intent:
- Workspace Web Console を Companion 固定 UI から、Worker list 起点の任意 Worker attach Console に再設計する。
Binding decisions / invariants:
- Console target authority は `runtime_id + worker_id`
- Browser は Runtime endpoint / credential / socket path / session path を扱わない。
- Sidebar に standalone `Console` category / navigation entry を残さない。
- Companion は通常 Worker として list から attach する。Companion 専用 route/sidebar/Console implementation は正規導線に残さない。
- 旧 `/console` route redirect/fallback 互換は残さない。
- Backend Worker API / Backend client-facing observation WS を使う。
- TUI の見た目や keybinding は移植しない。移植対象は `protocol::Event` rendering semantics。
- 未実装 Runtime/permission/advanced control UI、multi-worker split view、raw provider trace viewer は作らない。
Requirements / acceptance criteria:
- Workspace top Worker list から任意 Worker Console を開ける。
- Console route/state は `runtime_id + worker_id` を持つ。
- Worker detail/capabilities、bounded transcript/Snapshot相当、observation WS、input API を使う。
- `protocol::Event` 由来の user / assistant / thinking / tool call/result / status / error / usage / snapshot / in-flight を表示する。
- Metadata/diagnostics は compact header/details/collapsible area に逃がし、主 transcript 幅を過剰に占有しない。
- Streaming 非対応 Worker は transcript-only/manual refresh等の degrade path を明示。
- Focused Web UI tests/component tests を追加する。
- `deno task check/build`, `cargo check -p yoi`, `git diff --check`, `nix build .#yoi --no-link` が通る。
Implementation latitude:
- Route naming、Worker list UI placement、component split、protocol event state model、degrade UI の詳細は Coder が existing `web/workspace` style に合わせて選べる。
- Backend API に小さな不足があれば `crates/workspace-server` で最小 support を追加してよい。
- Existing Companion provider-less backend can remain as a normal worker source, but no special Console UI path。
Escalate if:
- Browser に Runtime endpoint/token/socket/session path を渡す必要が出る。
- old `/console` fallback を残さないと navigation が成立しないと判明する。
- Full TUI parity / tool UI / raw provider trace viewer / multi-worker split view が必要になる。
- Backend protocol/WS foundation の大幅 redesign が必要になる。
Validation:
- `cargo fmt --all`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task build`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- Companion special-case route/sidebar implementation が残ること。
- Route target が `runtime_id + worker_id` ではなく worker_id-only になること。
- Runtime endpoint/token/socket/session path leak。
- `protocol::Event` を簡易 chat message に潰しすぎること。
- Metadata/diagnostics が主 transcript を圧迫すること。
- Old `/console` redirect/fallback compatibility が残ること。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T17:46:39Z from: queued to: inprogress reason: routing_accepted_worker_console_redesign field: state -->
## State changed
Routing decision: implementation_ready。
Ticket body / relations / workspace state / accepted plan を確認し、Workspace Worker Console redesign は unblocked と判断した。Relations はなく、current `inprogress` は 0。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T17:47:26Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW2GCPYF-worker-console-redesign`
- Branch: `work/00001KW2GCPYF-worker-console-redesign`
- Coder Worker spawned: `yoi-coder-00001KW2GCPYF-worker-console`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `web/workspace/**`, `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
---
<!-- event: implementation_report author: yoi-coder-00001KW2GCPYF-worker-console at: 2026-06-26T18:07:17Z -->
## Implementation report
Implementation report:
実装完了。merge / Ticket 更新 / close / cleanup は行っていない。
Commit:
- `c3fed591095244223e6da7c84ac7f1a2e4cf8cb7 feat: worker attach workspace console`
変更概要:
### UI / routing
- 旧 canonical `/console` route を削除。
- `web/workspace/src/routes/console/+page.svelte` 削除。
- 新 Console route を追加。
- `/runtimes/[runtimeId]/workers/[workerId]/console`
- Console target authority は `runtime_id + worker_id`
- standalone Console / Companion sidebar entry を削除。
- `CompanionNavSection.svelte` 削除。
- sidebar は Workers section から各 Worker Console へ attach する形に変更。
- Workspace top Worker list に `Open Console` attach action を追加。
- Companion は特別 route ではなく通常 Worker row として attach される想定に変更。
### Worker Console
- Backend Worker API のみを使う Console を追加。
- Worker detail: `/api/runtimes/{runtime_id}/workers/{worker_id}`
- bounded transcript: `/api/runtimes/{runtime_id}/workers/{worker_id}/transcript?limit=200`
- input: `/api/runtimes/{runtime_id}/workers/{worker_id}/input`
- observation WS: `/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws`
- Browser 側では Runtime endpoint / credential / socket path / session path を扱わない。
- `protocol::Event` 由来の表示モデルを追加。
- user / assistant / thinking / tool / status / error / usage / snapshot / in-flight を区別表示。
- transcript-only item と observation event を source として分離。
- streaming unsupported Worker には明示的 degrade 表示を追加。
- “bounded transcript + manual refresh” の経路を提示。
- Worker detail / capabilities / diagnostics は右側 card の compact/collapsible 表示にして、transcript 幅を支配しない構成。
### tests
- Web Console projection/model tests 追加。
- protocol segment rendering
- event projection
- snapshot / in-flight state
- Worker target route href encoding
- UI contract tests 追加。
- Worker list/sidebar が Worker Console href を使うこと。
- standalone Companion nav が canonical でないこと。
- Console page が backend Worker APIs を使い `/api/companion` を使わないこと。
- `deno task test` を追加。
主な変更ファイル:
- `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte`
- `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts`
- `web/workspace/src/lib/workspace-console/model.ts`
- `web/workspace/src/lib/workspace-console/model.test.ts`
- `web/workspace/src/lib/workspace-console/worker-console.ui.test.ts`
- `web/workspace/src/lib/workspace-pages/WorkspacePage.svelte`
- `web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte`
- `web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte`
- `web/workspace/src/lib/workspace-sidebar/types.ts`
- `web/workspace/src/app.css`
- `web/workspace/deno.json`
- `web/workspace/tsconfig.json`
削除:
- `web/workspace/src/routes/console/+page.svelte`
- `web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte`
Validation:
- `cargo fmt --all`: success
- `cargo check -p yoi`: success
- `cd web/workspace && deno task test`: success6 tests passed
- `cd web/workspace && deno task check`: success0 errors / 0 warnings
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: successdirty tree warning は実装 commit 前の通常 warning。build 自体は完了)
Residual risks / review focus:
- 実 Runtime / live Worker に対するブラウザ手動操作は未実施。特に observation WS の実イベント順序と snapshot/in-flight 表示の見え方は reviewer 側で確認推奨。
- `protocol::Event` projection は現在の generated protocol union を広く扱う実装だが、UI は raw trace viewer ではなく compact transcript 表示に寄せている。イベント種類ごとの表示粒度が過不足ないか確認してほしい。
- `deno task test` の UI contract tests は component runtime harness ではなく、現行 Deno/Svelte 構成に合わせた focused contract tests。将来 component test harness が入るなら置き換え候補。
---
<!-- event: review author: yoi-reviewer-00001KW2GCPYF-worker-console at: 2026-06-26T18:11:54Z status: request_changes -->
## Review: request changes
Review result: request_changes
ブロッカー:
- `web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte`
- lines 263-267 の `$effect``refreshConsole()` を呼び、
- `refreshConsole()` lines 128-130 が `reloadToken += 1``reloadToken` を読み書きしています。
- Svelte runes の `$effect` は effect 内で同期的に読んだ stateネストした関数呼び出し含むを依存として追跡するため、この effect は `reloadToken` に依存しつつ自分で更新し、再実行ループになります。
- 結果として Worker detail/transcript fetch が継続的に走り、line 269 の observation WS も `reloadToken` 依存で再接続を繰り返すリスクがあります。Console が安定して開けないため、Acceptance の「Worker list can open a Console」「observation WS/input/transcript を適切に使う」を満たせません。
確認済みの良い点:
- old `web/workspace/src/routes/console` は存在せず、`/api/companion` 参照も Console 実装には残っていません。
- 新 route は `/runtimes/[runtimeId]/workers/[workerId]/console`
- Worker list/sidebar は `runtime_id + worker_id``encodeURIComponent` して Console href を生成。
- protocol event projection、snapshot/in-flight、非 streaming Worker の degrade 表示、compact/collapsible diagnostics card は実装されています。
検証は read-only 方針に従い、ソース確認・`find`/`rg`・git 状態確認のみ実施しました。ビルド/テスト再実行は artifact/cache 作成の可能性があるため未実施です。
---
<!-- event: implementation_report author: yoi-coder-00001KW2GCPYF-worker-console at: 2026-06-26T18:19:12Z -->
## Implementation report
Follow-up implementation report after review request_changes:
Commit:
- `a1083908b681b420cd5fd911ac00da01ff8b7e5d fix: stabilize worker console refresh`
修正内容:
- Worker Console の target-change `$effect``refreshConsole()` を経由して `reloadToken += 1` する構造を解消。
- `reloadToken` は rune state のまま、非 reactive な `nextReloadToken` から `advanceReloadToken()` で単調更新する形に変更。
- `reloadToken` 自体を同期 read/write しないため、target-change effect の dependency cycle を避ける。
- 初期表示 / target-change refresh は `loadConsoleData(target)` を直接呼ぶように分離。
- manual refresh は引き続き `advanceReloadToken()` してから Worker detail / transcript を再取得するため、WS reconnect semantics は維持。
- observation WS effect は `worker + reloadToken + consoleTarget` に依存し、manual refresh / target-change で安定して reconnect。
- focused static/contract test を追加・調整し、以下を確認。
- `reloadToken += 1` が残っていない。
- reload token advancement が `nextReloadToken` 経由。
- target-change effect が `refreshConsole()` 経由の dependency cycle に戻らない。
Validation:
- `cargo fmt --all`: success
- `cargo check -p yoi`: success
- `cd web/workspace && deno task test`: success6 passed
- `cd web/workspace && deno task check`: success0 errors / 0 warnings
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: successdirty tree warning は未 commit 差分によるもの。build は成功)
Residual risks / review focus:
- 実ブラウザ + live Worker での操作確認は未実施。
- 今回の fix は Svelte runes の dependency loop 解消に絞っている。WS の実接続タイミングや stale async response の UX は既存設計の範囲。
---
<!-- event: review author: yoi-reviewer-00001KW2GCPYF-worker-console at: 2026-06-26T18:21:17Z status: approve -->
## Review: approve
Review result: approve
確認結果:
- 対象 worktree は branch `work/00001KW2GCPYF-worker-console-redesign`、HEAD は fix commit `a1083908b681b420cd5fd911ac00da01ff8b7e5d`
- 前回 blocker は解消済み。
- `reloadToken += 1` は残っていない。
- target-change `$effect``refreshConsole()` を呼ばず、`consoleTarget` を読んで `advanceReloadToken()` + `loadConsoleData(target)` のみ実行している。
- `advanceReloadToken()` は非 reactive な `nextReloadToken` を増やして `reloadToken` に代入する形で、同じ effect 内で `reloadToken` を read/write する依存ループにはなっていない。
- observation WS effect は `worker + reloadToken + consoleTarget` に依存し、manual refresh / target change で reconnect される構造として妥当。
- regression なし。
- old `/console` route は存在しない。
- standalone `CompanionNavSection` / `/api/companion` 利用は Console 実装に戻っていない。
- Worker list/sidebar は `workerConsoleHref(worker)` 経由で `runtime_id + worker_id` route に attach している。
- Backend Worker detail/transcript/input/observation WS API の使用も維持されている。
検証:
- read-only のソース確認、git 状態確認、`find`/`rg` による route/API/sidebar 残存確認。ビルド・テスト再実行はファイル生成の可能性があるため未実施。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T18:21:56Z -->
## Implementation report
Integration / validation report:
Integration:
- Implementation branch `work/00001KW2GCPYF-worker-console-redesign` を orchestration branch に merge 済み。
- Merge commit: `864efe32 merge: 00001KW2GCPYF worker console redesign`
Included implementation commits:
- `c3fed591095244223e6da7c84ac7f1a2e4cf8cb7 feat: worker attach workspace console`
- `a1083908b681b420cd5fd911ac00da01ff8b7e5d fix: stabilize worker console refresh`
Validation in Orchestrator worktree:
- `cargo fmt --all --check`: success
- `cargo check -p yoi`: success
- `cd web/workspace && deno task test`: success6 tests passed
- `cd web/workspace && deno task check`: success0 errors / 0 warnings
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Review:
- Reviewer approve 済み。前回 blocker だった Svelte runes `$effect` / `reloadToken` dependency loop は解消済み。
Outcome:
- Acceptance criteria を満たしたため `done` へ進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-26T18:22:10Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Workspace Worker Console redesign の実装、review、merge、Orchestrator validation が完了した。
Done evidence:
- Merge commit: `864efe32 merge: 00001KW2GCPYF worker console redesign`
- Reviewer approve 済み。
- Orchestrator validation:
- `cargo fmt --all --check`: success
- `cargo check -p yoi`: success
- `cd web/workspace && deno task test`: success6 tests passed
- `cd web/workspace && deno task check`: success0 errors / 0 warnings
- `cd web/workspace && deno task build`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Scope:
- Workspace Web Console を Companion-specific route/sidebar から Worker list driven arbitrary Worker attach route (`runtime_id + worker_id`) へ再設計。
- Old `/console` canonical route and standalone Companion Console sidebar entry were removed.
- Advanced controls / multi-worker split / raw provider trace viewer / full auth-redaction policy は Non-goals として未実装。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-26T18:22:51Z -->
## Implementation report
Cleanup report:
- Child Workers stopped one-by-one and scope reclaimed:
- `yoi-coder-00001KW2GCPYF-worker-console`
- `yoi-reviewer-00001KW2GCPYF-worker-console`
- Child implementation worktree removed:
- `/home/hare/Projects/yoi/.worktree/00001KW2GCPYF-worker-console-redesign`
- Child implementation branch removed:
- `work/00001KW2GCPYF-worker-console-redesign`
Operational note:
- StopPod was executed sequentially, not in parallel.
--- ---

View File

@ -6,10 +6,12 @@
"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 -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "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", "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", "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"
}, },
"imports": { "imports": {
"$lib/": "./src/lib/",
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",

View File

@ -816,3 +816,214 @@
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
.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;
}
}

View File

@ -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",
);
});

View File

@ -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);
}
}

View File

@ -0,0 +1,91 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: string | URL): Promise<string>;
};
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(target.runtimeId)}/workers/${encodeURIComponent(target.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",
);
assert(
consolePage.includes("function advanceReloadToken()") &&
consolePage.includes("nextReloadToken += 1") &&
!consolePage.includes("reloadToken += 1"),
"reload token advancement should not synchronously read and write the rune state",
);
assert(
consolePage.includes(
"advanceReloadToken();\n void loadConsoleData(target);",
) &&
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
"target-change effect should load data without depending on manual refresh state reads",
);
});

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte'; import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
import { workerConsoleHref } from '$lib/workspace-console/model';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte'; import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type { import type {
Diagnostic, Diagnostic,
@ -477,6 +478,7 @@
<th>State</th> <th>State</th>
<th>Workspace</th> <th>Workspace</th>
<th>Implementation</th> <th>Implementation</th>
<th>Attach</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -492,6 +494,7 @@
<td>{worker.state} · {worker.status}</td> <td>{worker.state} · {worker.status}</td>
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td> <td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
<td>{worker.implementation.kind}</td> <td>{worker.implementation.kind}</td>
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@ -1,20 +0,0 @@
<script lang="ts">
type Props = {
currentPath?: string;
};
let { currentPath = '/' }: Props = $props();
const active = $derived(currentPath === '/console');
</script>
<section class="sidebar-section" aria-labelledby="companion-console-heading">
<div class="section-heading-row">
<h2 id="companion-console-heading">Console</h2>
<span class="section-count">MVP</span>
</div>
<a class:active class="nav-item" href="/console" aria-current={active ? 'page' : undefined}>
<span class="item-title">Companion Console</span>
<span class="item-meta">status · transcript · send</span>
</a>
</section>

View File

@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { ListResponse, Worker } from './types'; import type { ListResponse, Worker } from './types';
const MAX_VISIBLE_WORKERS = 6; const MAX_VISIBLE_WORKERS = 6;
type Props = {
currentPath?: string;
};
let { currentPath = '/' }: Props = $props();
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<Worker[]>([]); let workers = $state<Worker[]>([]);
@ -63,15 +70,18 @@
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p> <p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
{:else} {:else}
<ul class="nav-list" aria-label="Workers"> <ul class="nav-list" aria-label="Workers">
{#each workers as worker (worker.worker_id)} {#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
<li class="nav-item worker-nav-item"> {@const href = workerConsoleHref(worker)}
<li>
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
<span class="worker-title-row"> <span class="worker-title-row">
<span class="item-title">{worker.label}</span> <span class="item-title">{worker.label}</span>
<span class="worker-task-title">-</span> <span class="worker-task-title">-</span>
</span> </span>
<span class="item-meta"> <span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} {worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
</span> </span>
</a>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import CompanionNavSection from './CompanionNavSection.svelte';
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte';
@ -42,9 +41,8 @@
</header> </header>
<nav class="sidebar-sections" aria-label="Workspace sections"> <nav class="sidebar-sections" aria-label="Workspace sections">
<CompanionNavSection {currentPath} />
<RepositoriesNavSection {workspace} {currentPath} /> <RepositoriesNavSection {workspace} {currentPath} />
<ObjectivesNavSection {currentPath} /> <ObjectivesNavSection {currentPath} />
<WorkersNavSection /> <WorkersNavSection {currentPath} />
</nav> </nav>
</aside> </aside>

View File

@ -1,9 +1,11 @@
export type { import type {
Event as PodProtocolEvent, Event as PodProtocolEvent,
Method as PodProtocolMethod, Method as PodProtocolMethod,
Segment as PodProtocolSegment, Segment as PodProtocolSegment
} from '$lib/generated/protocol'; } from '$lib/generated/protocol';
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
export type ExtensionPoint = { export type ExtensionPoint = {
status: string; status: string;
note: string; note: string;
@ -93,6 +95,53 @@ export type Worker = {
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
}; };
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerInputResult = {
state: WorkerOperationState;
runtime_id: string;
worker_id: string;
transcript_sequence?: number | null;
event_id?: number | null;
diagnostics: Diagnostic[];
};
export type WorkerTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
content: string;
event_id: number;
};
export type WorkerTranscriptProjection = {
state: WorkerOperationState;
runtime_id: string;
worker_id: string;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: WorkerTranscriptItem[];
diagnostics: Diagnostic[];
};
export type ClientWorkerEventWsEnvelope = {
cursor: string;
event_id: string;
runtime_id: string;
worker_id: string;
payload: PodProtocolEvent;
};
export type ClientWorkerEventWsDiagnostic = {
code: string;
message: string;
};
export type ClientWorkerEventWsFrame =
| { kind: 'event'; envelope: ClientWorkerEventWsEnvelope }
| { kind: 'diagnostic'; diagnostic: ClientWorkerEventWsDiagnostic };
export type ListResponse<T> = { export type ListResponse<T> = {
workspace_id: string; workspace_id: string;
limit: number; limit: number;

View File

@ -1,281 +0,0 @@
<script lang="ts">
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type {
CompanionMessageResponse,
CompanionState,
CompanionStatusResponse,
CompanionTranscriptItem,
CompanionTranscriptProjection,
Diagnostic,
WorkspaceResponse
} from '$lib/workspace-sidebar/types';
let workspace = $state<WorkspaceResponse | null>(null);
let workspaceError = $state<string | null>(null);
let status = $state<CompanionStatusResponse | null>(null);
let transcript = $state<CompanionTranscriptProjection | null>(null);
let draft = $state('');
let operationState = $state<CompanionState>('ready');
let error = $state<string | null>(null);
let timeoutNotice = $state<string | null>(null);
let requestId = 0;
const currentPath = '/console';
const messages = $derived(transcript?.items ?? []);
const diagnostics = $derived(mergeDiagnostics(status?.diagnostics ?? [], transcript?.diagnostics ?? []));
const sending = $derived(operationState === 'busy');
const canSend = $derived(draft.trim().length > 0 && !sending);
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function postJson<T>(path: string, body: unknown, timeoutMs = 45_000): Promise<T> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
detail = '';
}
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json() as Promise<T>;
} catch (requestError) {
if (requestError instanceof DOMException && requestError.name === 'AbortError') {
operationState = 'timeout';
timeoutNotice = 'Workspace server request timed out before a Companion response arrived.';
}
throw requestError;
} finally {
window.clearTimeout(timeout);
}
}
async function loadWorkspace() {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace');
} catch (loadError) {
workspaceError = loadError instanceof Error ? loadError.message : String(loadError);
workspace = null;
}
}
async function loadCompanion() {
error = null;
timeoutNotice = null;
try {
const [nextStatus, nextTranscript] = await Promise.all([
getJson<CompanionStatusResponse>('/api/companion/status'),
getJson<CompanionTranscriptProjection>('/api/companion/transcript?limit=200')
]);
status = nextStatus;
transcript = nextTranscript;
operationState = nextStatus.state === 'error' ? 'error' : 'ready';
} catch (loadError) {
error = loadError instanceof Error ? loadError.message : String(loadError);
operationState = 'error';
}
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
if (!content || sending) {
return;
}
const currentRequest = ++requestId;
error = null;
timeoutNotice = null;
operationState = 'busy';
try {
const response = await postJson<CompanionMessageResponse>('/api/companion/messages', { content });
if (currentRequest !== requestId) {
return;
}
operationState = response.state;
transcript = response.transcript;
if (response.worker || status) {
status = {
state: response.state === 'accepted' ? 'ready' : response.state,
worker: response.worker ?? status?.worker ?? null,
transport: status?.transport ?? {
kind: 'providerless_backend_internal',
completion: 'synchronous_request_response',
limitation: 'Companion transport metadata was not available during this response.'
},
diagnostics: response.diagnostics
};
}
if (response.state === 'accepted') {
draft = '';
operationState = 'ready';
} else if (response.state === 'busy') {
error = 'Companion is busy with another message.';
} else if (response.state === 'rejected') {
error = diagnosticsToText(response.diagnostics) || 'Companion rejected the message.';
} else if (response.state === 'error') {
error = diagnosticsToText(response.diagnostics) || 'Companion returned an error.';
}
} catch (sendError) {
if (currentRequest !== requestId) {
return;
}
if (operationState !== 'timeout') {
operationState = 'error';
}
error = sendError instanceof Error ? sendError.message : String(sendError);
}
}
async function cancelMessage() {
++requestId;
operationState = 'cancelled';
try {
const response = await postJson<CompanionMessageResponse>('/api/companion/cancel', { reason: 'browser_cancel' }, 10_000);
transcript = response.transcript;
status = status
? { ...status, state: response.state, diagnostics: response.diagnostics }
: status;
} catch (cancelError) {
error = cancelError instanceof Error ? cancelError.message : String(cancelError);
operationState = 'error';
}
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
return groups.flat();
}
function diagnosticsToText(items: Diagnostic[]): string {
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
}
function itemClass(item: CompanionTranscriptItem): string {
if (item.role === 'assistant') {
return 'assistant';
}
if (item.role === 'user') {
return 'user';
}
return 'system';
}
$effect(() => {
void loadWorkspace();
void loadCompanion();
});
</script>
<svelte:head>
<title>Companion Console · Yoi Workspace</title>
<meta name="description" content="Workspace Companion Web Console MVP" />
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
<main class="shell console-shell">
<section class="console-header card">
<div>
<p class="eyebrow">Backend-internal Companion</p>
<h2>Companion Console</h2>
<p class="section-note">
Browser traffic stays behind Workspace API projections. No Worker socket, session path,
runtime credential, or local session file is exposed to the frontend.
</p>
</div>
<div class="console-status" data-state={operationState}>
<span>{operationState}</span>
{#if status?.worker}
<small>{status.worker.label}</small>
{:else}
<small>worker pending</small>
{/if}
</div>
</section>
{#if status?.transport}
<section class="card console-transport" aria-label="Companion transport">
<div>
<dt>Transport</dt>
<dd>{status.transport.kind}</dd>
</div>
<div>
<dt>Completion</dt>
<dd>{status.transport.completion}</dd>
</div>
<p>{status.transport.limitation}</p>
</section>
{/if}
{#if error || timeoutNotice || diagnostics.length > 0}
<section class="card console-diagnostics" aria-label="Companion diagnostics">
{#if timeoutNotice}
<p class="diagnostic warning">{timeoutNotice}</p>
{/if}
{#if error}
<p class="diagnostic error">{error}</p>
{/if}
{#each diagnostics as diagnostic}
<p class={`diagnostic ${diagnostic.severity}`}>{diagnostic.code}: {diagnostic.message}</p>
{/each}
</section>
{/if}
<section class="card transcript-card" aria-label="Companion transcript">
<div class="runtime-heading">
<h3>Transcript</h3>
<span>{transcript?.total_items ?? 0} items</span>
</div>
{#if messages.length === 0}
<p class="empty-state">No Companion messages yet. Send a message to exercise the backend boundary.</p>
{:else}
<ol class="transcript-list">
{#each messages as message (message.sequence)}
<li class={`transcript-item ${itemClass(message)}`}>
<div class="message-meta">
<strong>{message.role}</strong>
<span>{message.status}</span>
<time datetime={message.created_at}>{message.created_at}</time>
</div>
<p>{message.content}</p>
</li>
{/each}
</ol>
{/if}
</section>
<form class="card composer-card" onsubmit={sendMessage}>
<label for="companion-message">Message Companion</label>
<textarea
id="companion-message"
bind:value={draft}
rows="4"
maxlength="8000"
placeholder="Ask or note something for the backend Companion boundary…"
disabled={sending}
></textarea>
<div class="composer-actions">
<span>{draft.trim().length}/8000</span>
<button type="button" class="secondary" onclick={loadCompanion} disabled={sending}>Refresh</button>
<button type="button" class="secondary" onclick={cancelMessage} disabled={!sending}>Cancel</button>
<button type="submit" disabled={!canSend}>Send</button>
</div>
</form>
</main>
</div>

View File

@ -0,0 +1,439 @@
<script lang="ts">
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import {
projectConsole,
workerConsolePath,
type ConsoleLine
} from '$lib/workspace-console/model';
import type {
ClientWorkerEventWsFrame,
Diagnostic,
Worker,
WorkerInputResult,
WorkerTranscriptProjection,
WorkspaceResponse
} from '$lib/workspace-sidebar/types';
type Props = {
data: {
runtimeId: string;
workerId: string;
};
};
let { data }: Props = $props();
const runtimeId = $derived(data.runtimeId);
const workerId = $derived(data.workerId);
const currentPath = $derived(workerConsolePath(runtimeId, workerId));
let workspace = $state<WorkspaceResponse | null>(null);
let workspaceError = $state<string | null>(null);
let worker = $state<Worker | null>(null);
let workerError = $state<string | null>(null);
let transcript = $state<WorkerTranscriptProjection | null>(null);
let transcriptError = $state<string | null>(null);
let draft = $state('');
let sending = $state(false);
let sendError = $state<string | null>(null);
let streamState = $state<'connecting' | 'open' | 'unsupported' | 'closed' | 'error'>('connecting');
let streamDiagnostics = $state<Diagnostic[]>([]);
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
let nextReloadToken = 0;
let reloadToken = $state(0);
type ConsoleTarget = {
runtimeId: string;
workerId: string;
};
const consoleTarget = $derived({ runtimeId, workerId });
const projection = $derived(
projectConsole(
transcript?.items ?? [],
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
)
);
const lines = $derived(projection.lines);
const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
);
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
const transcriptOnly = $derived(
worker && !worker.capabilities.can_stream_events
? 'Streaming observation is not available for this Worker. Console is using bounded transcript plus manual refresh.'
: null
);
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function postJson<T>(path: string, body: unknown, timeoutMs = 30_000): Promise<T> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
detail = '';
}
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeout);
}
}
async function loadWorkspace() {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace');
} catch (error) {
workspaceError = error instanceof Error ? error.message : String(error);
workspace = null;
}
}
async function loadWorker(target: ConsoleTarget) {
workerError = null;
try {
worker = await getJson<Worker>(
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`
);
} catch (error) {
workerError = error instanceof Error ? error.message : String(error);
worker = null;
}
}
async function loadTranscript(target: ConsoleTarget) {
transcriptError = null;
try {
transcript = await getJson<WorkerTranscriptProjection>(
`/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`
);
} catch (error) {
transcriptError = error instanceof Error ? error.message : String(error);
transcript = null;
}
}
async function loadConsoleData(target: ConsoleTarget) {
await Promise.all([loadWorker(target), loadTranscript(target)]);
}
function advanceReloadToken(): number {
nextReloadToken += 1;
reloadToken = nextReloadToken;
return nextReloadToken;
}
async function refreshConsole() {
advanceReloadToken();
await loadConsoleData(consoleTarget);
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
if (!content || sending || !worker?.capabilities.can_accept_input) {
return;
}
sending = true;
sendError = null;
try {
const result = await postJson<WorkerInputResult>(
`/api/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`,
{ kind: 'user', content }
);
if (result.state === 'accepted') {
draft = '';
} else {
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
}
await loadTranscript(consoleTarget);
} catch (error) {
sendError = error instanceof Error ? error.message : String(error);
} finally {
sending = false;
}
}
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
if (!targetWorker) {
streamState = 'closed';
return;
}
if (!targetWorker.capabilities.can_stream_events) {
streamState = 'unsupported';
streamDiagnostics = [
{
code: 'worker_streaming_unsupported',
severity: 'info',
message: 'This Worker does not expose backend-proxied observation streaming; transcript refresh remains available.'
}
];
return;
}
streamState = 'connecting';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(
`${protocol}//${window.location.host}/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
target.workerId
)}/events/ws`
);
ws.onopen = () => {
if (token === reloadToken) {
streamState = 'open';
}
};
ws.onmessage = (message) => {
if (token !== reloadToken) {
return;
}
try {
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
if (frame.kind === 'event') {
observedEvents = [
...observedEvents,
{
cursor: frame.envelope.cursor,
event: frame
}
].slice(-500);
} else {
streamDiagnostics = [
...streamDiagnostics,
{
code: frame.diagnostic.code,
severity: 'warning',
message: frame.diagnostic.message
}
];
}
} catch (error) {
streamDiagnostics = [
...streamDiagnostics,
{
code: 'worker_observation_frame_invalid',
severity: 'warning',
message: error instanceof Error ? error.message : String(error)
}
];
}
};
ws.onerror = () => {
if (token === reloadToken) {
streamState = 'error';
streamDiagnostics = [
...streamDiagnostics,
{
code: 'worker_observation_ws_error',
severity: 'warning',
message: 'Backend observation WebSocket failed; transcript refresh remains available.'
}
];
}
};
ws.onclose = () => {
if (token === reloadToken && streamState !== 'error') {
streamState = 'closed';
}
};
return () => ws.close();
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
return groups.flat();
}
function diagnosticsToText(items: Diagnostic[]): string {
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
}
function lineClass(line: ConsoleLine): string {
return line.error ? 'error' : line.kind;
}
$effect(() => {
void loadWorkspace();
});
$effect(() => {
const target = consoleTarget;
observedEvents = [];
streamDiagnostics = [];
advanceReloadToken();
void loadConsoleData(target);
});
$effect(() => connectObservation(worker, reloadToken, consoleTarget));
</script>
<svelte:head>
<title>Worker Console · Yoi Workspace</title>
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
<main class="shell console-shell worker-console-shell">
<section class="console-header card">
<div>
<p class="eyebrow">Worker attach Console</p>
<h2>{worker?.label ?? workerId}</h2>
<p class="section-note">
Target authority is <code>runtime_id</code> + <code>worker_id</code>. Browser traffic uses Workspace Backend Worker APIs only;
Runtime endpoints, credentials, socket paths, and session paths are not exposed.
</p>
</div>
<div class="console-status-pill" class:warn={streamState !== 'open'}>
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
</div>
</section>
<section class="console-grid">
<article class="card transcript-card worker-transcript-card">
<header class="transcript-toolbar">
<div>
<h3>Transcript and protocol events</h3>
{#if projection.status || projection.usage}
<p class="section-note">
{#if projection.status}status: {projection.status}{/if}
{#if projection.status && projection.usage} · {/if}
{#if projection.usage}usage: {projection.usage}{/if}
</p>
{/if}
</div>
<button type="button" class="secondary-button" onclick={refreshConsole}>Refresh</button>
</header>
{#if workerError}
<p class="error">{workerError}</p>
{/if}
{#if transcriptError}
<p class="error">{transcriptError}</p>
{/if}
{#if transcriptOnly}
<p class="section-note degrade-note">{transcriptOnly}</p>
{/if}
{#if lines.length === 0}
<p>No transcript items or observation events are available for this Worker yet.</p>
{:else}
<ol class="transcript worker-transcript">
{#each lines as item}
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
<div class="message-heading">
<span>{item.title}</span>
<small>{item.source}{item.streaming ? ' · streaming' : ''}</small>
</div>
<pre>{item.body || '—'}</pre>
{#if item.detail || item.cursor}
<details class="message-detail">
<summary>metadata</summary>
{#if item.detail}<p>{item.detail}</p>{/if}
{#if item.cursor}<code>{item.cursor}</code>{/if}
</details>
{/if}
</li>
{/each}
</ol>
{/if}
</article>
<aside class="console-side-card card">
<h3>Worker detail</h3>
{#if worker}
<dl>
<div>
<dt>Runtime</dt>
<dd><code>{worker.runtime_id}</code></dd>
</div>
<div>
<dt>Worker</dt>
<dd><code>{worker.worker_id}</code></dd>
</div>
<div>
<dt>Host</dt>
<dd><code>{worker.host_id}</code></dd>
</div>
<div>
<dt>Role / profile</dt>
<dd>{worker.role ?? 'unknown'} / {worker.profile ?? 'unknown'}</dd>
</div>
<div>
<dt>Workspace</dt>
<dd>{worker.workspace.visibility} · {worker.workspace.identity}</dd>
</div>
<div>
<dt>Implementation</dt>
<dd>{worker.implementation.kind} · {worker.implementation.display_hint}</dd>
</div>
</dl>
<details class="metadata-details">
<summary>Capabilities</summary>
<ul>
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
<li>stream: {worker.capabilities.can_stream_events ? 'available' : 'unsupported'}</li>
<li>bounded transcript: {worker.capabilities.can_read_bounded_transcript ? 'available' : 'unsupported'}</li>
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
</ul>
</details>
{:else if !workerError}
<p>Loading Worker detail…</p>
{/if}
{#if diagnostics.length > 0}
<details class="metadata-details" open={streamState === 'error'}>
<summary>Diagnostics ({diagnostics.length})</summary>
<ul>
{#each diagnostics as diagnostic}
<li>
<strong>{diagnostic.severity}</strong>
<code>{diagnostic.code}</code>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
</details>
{/if}
</aside>
</section>
<form class="console-composer card" onsubmit={sendMessage}>
<label for="worker-console-message">Send user input</label>
<textarea
id="worker-console-message"
bind:value={draft}
placeholder={worker?.capabilities.can_accept_input ? 'Message this Worker through the Backend input API…' : 'Input is unsupported for this Worker'}
disabled={!worker?.capabilities.can_accept_input || sending}
></textarea>
<div class="composer-actions">
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
{#if sendError}<p class="error">{sendError}</p>{/if}
</div>
</form>
</main>
</div>

View File

@ -0,0 +1,8 @@
export function load(
{ params }: { params: { runtimeId: string; workerId: string } },
) {
return {
runtimeId: params.runtimeId,
workerId: params.workerId,
};
}

View File

@ -2,6 +2,7 @@
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"allowImportingTsExtensions": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,