diff --git a/docs/plans/2026-04-22-chat-mode-design.md b/docs/plans/2026-04-22-chat-mode-design.md new file mode 100644 index 00000000..6ab54ec1 --- /dev/null +++ b/docs/plans/2026-04-22-chat-mode-design.md @@ -0,0 +1,337 @@ +# Chat mode — design + +Date: 2026-04-22 +Status: Approved, ready for implementation planning + +## Context + +`the-controller-daemon` (separate Rust project at `~/projects/the-controller-daemon/`) is a long-running local daemon that owns Claude Code and Codex agent sessions and exposes them through an HTTP + WebSocket API with a typed event schema (channels: `inbox`, `outbox`, `system`). Sessions, inbox commands, and outbox events are persisted to SQLite; clients can disconnect and reconnect with `?since=`. + +The daemon's own design doc (`~/projects/the-controller-daemon/docs/plans/2026-04-20-daemon-chat-architecture-design.md`) explicitly defers "Tauri app integration (new `development-v2` UI mode built on top of the daemon)" as follow-up work. This project is that follow-up: a new `chat` workspace mode in the Tauri app that renders daemon sessions as structured messages. + +## Scope + +**In scope:** + +- New workspace mode `chat` alongside `development` and `agents`. +- Full multi-session workspace: session list, create / pick / delete daemon sessions, tied to existing Tauri `Project` entities via `cwd`. +- Support both Claude and Codex from day one. +- Live streaming of `agent_text_delta`; finalized on `agent_text`. +- Tool calls rendered inline, collapsed by default. +- Tool approval via inline Approve / Deny buttons in the transcript. +- Manual daemon lifecycle — the Tauri app assumes the user runs `the-controller-daemon` externally. Disconnected state shows an empty-state with retry. +- Coexist with `development` mode. No migration / deprecation here. + +**Out of scope:** + +- Auto-spawning the daemon from the Tauri app (revisit later). +- Sunsetting the `development` (terminal) mode. +- Remote daemon access. +- Load testing many concurrent sessions. +- Arbitrary-cwd sessions independent of projects (project-scoped only in v1). + +## Architecture overview + +The Svelte frontend talks to the daemon directly over HTTP + WebSocket (Approach A). One thin Tauri command `read_daemon_token() -> Result` reads `~/.the-controller/daemon.token`. Everything else — session CRUD, message send, WS subscription with replay — lives in Svelte. + +``` +┌──────────────────────────────┐ HTTP + WS ┌──────────────────────────┐ +│ Tauri app (chat mode) │ ───────────────► │ the-controller-daemon │ +│ │ localhost + │ (Rust, long-lived) │ +│ src/lib/daemon/ │ bearer token │ │ +│ client.ts stream.ts │ │ │ +│ types.ts store.ts │ │ │ +│ │ │ │ +│ Tauri cmd: read_daemon_token│ │ │ +└──────────────────────────────┘ └──────────────────────────┘ + │ + ├── ~/.the-controller/daemon.db + ├── ~/.the-controller/daemon.token + └── ~/.the-controller/daemon.pid +``` + +### Why Approach A (frontend-direct) + +- The daemon's HTTP+WS is already typed, debuggable, and stable; a Rust shim in `src-tauri` would just be passthrough. +- Tauri webview is a trusted client; bearer token in JS memory is acceptable. +- Fastest path to shipping a working chat UI. +- If we later need remote daemons or a central reconnection manager, we migrate the hot path into Rust then. The Svelte-side types, store, and UI don't need to change. + +Approaches B (Rust owns everything) and C (hybrid: Rust owns WS only) were considered and rejected for v1 on cost / velocity grounds. + +## Workspace mode integration + +The app's current mode switcher is `Space` → mode key (`HotkeyManager.svelte:87-103`, `WorkspaceModePicker.svelte:8-11`). We add a third mode: + +- `WorkspaceMode` type (`src/lib/stores.ts:131`) gains `"chat"`. +- `WorkspaceModePicker.svelte` adds `{ key: "c", id: "chat", label: "Chat" }`. +- `HotkeyManager.svelte:handleWorkspaceModeKey` gains a branch for `"c"` → `workspaceMode.set("chat")`. +- `App.svelte:375-379` grows a third branch: `{:else if workspaceModeState.current === "chat"} {:else} {/if}` (`development` stays as the default `:else`). +- `HotkeyHelp.svelte` grows a case for chat-mode hotkeys. +- `focusForModeSwitch` gains a `chat` branch — focus the active chat session, else the first project. + +## UI layout + +Three-pane layout, reusing the existing sidebar. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Existing Sidebar (projects) │ +│ ┌─────────────────┐ ┌────────────────────────────────────────────┐ │ +│ │ Project tree │ │ ChatView │ │ +│ │ ── project A │ │ ┌──────────────────────────────────────┐ │ │ +│ │ ├─ chat 1 │ │ │ header: label · agent · status │ │ │ +│ │ └─ chat 2 │ │ ├──────────────────────────────────────┤ │ │ +│ │ ── project B │ │ │ transcript │ │ │ +│ │ └─ + New chat│ │ │ user bubble │ │ │ +│ │ │ │ │ agent bubble (markdown, deltas) │ │ │ +│ │ │ │ │ 🔧 tool_call ▸ (click to expand) │ │ │ +│ │ │ │ │ [approve] [deny] (pending approval) │ │ │ +│ │ │ │ ├──────────────────────────────────────┤ │ │ +│ │ │ │ │ ChatInput (Cmd+Enter send, Esc intr) │ │ │ +│ │ │ │ └──────────────────────────────────────┘ │ │ +│ └─────────────────┘ └────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### New components (`src/lib/chat/`) + +- `ChatWorkspace.svelte` — root mounted when `workspaceMode === "chat"`. Owns Sidebar + ChatView composition for chat mode; handles empty states (no daemon, no projects, no sessions). +- `ChatSessionList.svelte` — rendered inside `Sidebar.svelte` when in chat mode (parallel to the terminal session list). Shows daemon sessions grouped by project, with status dots. Includes "+ New chat" per project. +- `NewChatDialog.svelte` — modal opened from "+ New chat". Agent dropdown (`claude` | `codex`), optional initial prompt, confirm. `cwd` is inherited from the project's main worktree. +- `ChatView.svelte` — header + transcript + input. Subscribes to one session's WS stream on mount; tears down on unmount / session switch. +- `Transcript.svelte` — virtualized list (reuse existing virtualization if present, else plain scroll container for v1). Renders logical blocks. +- `MessageBlock.svelte` — dispatches on event kind to subcomponents: `UserMessage`, `AgentMessage` (markdown, in-progress indicator for deltas), `ThinkingBlock`, `ToolCallBlock` (collapsed by default; expanded shows input + paired `tool_result`), `ErrorBlock`, `StatusLine`. +- `ChatInput.svelte` — textarea, `Cmd+Enter` sends, `Esc` interrupts when agent is running, disabled on `ended` / `failed`. +- `DaemonEmptyState.svelte` — shown when token read or HTTP ping fails. Retry button. + +### Sidebar + navigation + +`Sidebar.svelte` already branches on `currentMode` (line 27). Add a third branch for `chat` rendering `ChatSessionList`. The keyboard nav shape from `HotkeyManager.svelte:getVisibleItems` extends to chat items (project → chats → next project). `Enter` on a chat activates it; `Enter` on "+ New chat" opens `NewChatDialog`. + +## Data flow + +### Send a user message + +``` +ChatInput → store.sendUserText(sid, text) + client.post(`/sessions/${sid}/messages`, {kind:"user_text", text}) + → 202 {seq} + → store optimistic-inserts an inbox event at that seq + → WS delivers the same event; dedupe on (sid, seq) +``` + +### Receive agent output + +``` +stream.ts WS for active session + outbox:agent_text_delta → append to inProgressBlocks[block_id] + outbox:agent_text → drop inProgress for block_id; finalize + outbox:tool_call → render collapsed block (awaiting tool_result by call_id) + outbox:tool_result → attach to its tool_call at render time + outbox:token_usage → update transcript.tokenUsage + outbox:error → inline error block + system:status_changed → update transcript.statusState + system:session_ended → disable input; show ended banner +``` + +### Reconnect + +``` +WS close (any reason) → exponential backoff cap 10s + reconnect with ?since= + daemon replays seq > lastSeq (finalized only; no deltas) then switches live + any partial inProgressBlocks are dropped; finalized replay fills the gap +``` + +## Types + +Mirrors `the-controller-daemon/src/model.rs` exactly in `src/lib/daemon/types.ts`. + +```ts +export type Agent = "claude" | "codex"; +export type SessionStatus = "starting" | "running" | "interrupted" | "ended" | "failed"; +export type Channel = "inbox" | "outbox" | "system"; + +export interface DaemonSession { + id: string; + label: string; + agent: Agent; + cwd: string; + args: string[]; + status: SessionStatus; + native_session_id: string | null; + pid: number | null; + created_at: number; + updated_at: number; + ended_at: number | null; + end_reason: string | null; +} + +export interface EventRecord { + session_id: string; + seq: number; + channel: Channel; + kind: string; + payload: unknown; + created_at: number; + applied_at: number | null; +} + +export type OutboxEvent = + | { kind: "agent_text"; payload: { message_id: string; block_id: string; text: string; role?: string } } + | { kind: "agent_text_delta"; payload: { message_id: string; block_id: string; delta: string; role?: string } } + | { kind: "agent_thinking"; payload: { message_id: string; block_id: string; text: string } } + | { kind: "tool_call"; payload: { call_id: string; tool: string; input: unknown } } + | { kind: "tool_result"; payload: { call_id: string; output: unknown; is_error: boolean } } + | { kind: "token_usage"; payload: { input: number; output: number; cache_read: number; cache_write: number } } + | { kind: "error"; payload: { code: string; message: string; detail?: unknown } }; + +export type InboxEvent = + | { kind: "user_text"; payload: { text: string } } + | { kind: "interrupt"; payload: {} } + | { kind: "tool_approval"; payload: { call_id: string; approved: boolean; reason?: string } }; + +export type SystemEvent = + | { kind: "session_started"; payload: { agent: Agent; cwd: string; args: string[] } } + | { kind: "session_ended"; payload: { end_reason: string; exit_code?: number; signal?: string } } + | { kind: "session_interrupted"; payload: { reason: string } } + | { kind: "session_resumed"; payload: { native_session_id: string } } + | { kind: "agent_crashed"; payload: { exit_code?: number; signal?: string; last_stderr_tail?: string } } + | { kind: "status_changed"; payload: { state: "starting"|"idle"|"working"|"waiting_for_tool_approval"|"failed"; idle_ms?: number } }; +``` + +## Store shape + +Module-level Svelte 5 `$state` in `src/lib/daemon/store.ts`. + +```ts +interface DaemonState { + token: string | null; + reachable: boolean; + sessions: Map; + transcripts: Map; + streams: Map; +} + +interface TranscriptState { + events: EventRecord[]; // finalized, ordered by seq + lastSeq: number; + inProgressBlocks: Map; // block_id -> accumulated delta text + tokenUsage: OutboxEvent["payload"] | null; + statusState: SystemEvent["payload"]["state"] | null; +} + +interface StreamState { + ws: WebSocket | null; + connected: boolean; + reconnectAt: number | null; +} +``` + +**Key properties:** + +- `Transcript.svelte` folds `inProgressBlocks` onto the tail at render time — deltas show as a partial agent block; `agent_text` finalize drops the partial. +- `tool_result` is joined to its `tool_call` by `call_id` at render time (flat event log matches the daemon). +- Dedupe: every incoming event keyed by `(sid, seq)`. Optimistic local insert on `POST /messages` uses the 202 `seq`; WS delivery of the same event is a no-op. +- Store is module-scoped → state survives mode switches. WS is kept open only for the active session; inactive sessions rebuild with `?since=` on reopen. +- Project↔daemon-session mapping: `DaemonSession.cwd === project.path` (exact string). Sessions with no matching project land in an "Other" group (not hidden; users may create sessions via `curl`). + +## Error handling + +### Daemon unreachable + +**Triggers:** token file missing or unreadable; HTTP connect refused; WS connect fails before any session is open. + +**UX:** `ChatWorkspace` renders `DaemonEmptyState` full-panel — title "Daemon not running", start command shown (`./target/release/the-controller-daemon`), Retry button. + +**Behavior:** ping = `GET /sessions`. On mount and on Retry. No automatic polling. + +### HTTP errors mid-session + +| Status | Response | +|---|---| +| 401 | Re-read token once, retry once. Still 401 → `reachable = false`, render `DaemonEmptyState`. | +| 404 | Session deleted out-of-band. Remove from store, close WS, toast "Session no longer exists." | +| 409 | Session ended. Disable input; banner "Session ended. Start a new one." Transcript preserved. | +| 422 | Log + toast "Invalid request — please report." Input stays enabled. | +| 503 | Toast "Daemon storage error." Input stays enabled. | +| Network error | Inline retry icon next to input. No auto-retry — duplicate delivery worse than visible failure. | + +### WS disconnection + +- Exponential backoff cap 10s, reconnect with `?since=`. +- Header indicator "Reconnecting…"; at cap, "Disconnected — retry" with manual button. +- `1011 bus_lagged` → same reconnect path. +- On reconnect: partial `inProgressBlocks` are dropped; finalized replay fills them. + +### Agent-side errors (outbox / system) + +- `error` outbox event → red inline block in transcript (monospace code + message). +- `agent_crashed` system event → `ErrorBlock` with exit code / signal / last stderr tail (collapsed, expandable). Input disabled; status indicator red. +- `session_ended` with `end_reason = "resume_failed"` → neutral system block. Input disabled. +- `session_interrupted` → neutral block. If followed by `session_resumed` shortly, collapse into a single "Reconnected" note. + +### Tool approval + +No client timeout. Inline Approve / Deny remain present while `status_changed.state === "waiting_for_tool_approval"`. + +### Interrupt + +UI sends the inbox command; daemon handles the escalation ladder internally. If no `status_changed` in ~5s, non-blocking hint: "Interrupt sent — agent may still be finishing." + +## Testing strategy + +### Tier 1 — unit (Vitest) + +- **Event reducer:** sequence of events → derived transcript matches fixture. Covers delta accumulation, finalize-drops-in-progress, tool_result join by call_id, dedupe on repeated `(sid, seq)`. +- **Types round-trip:** JSON fixtures from `the-controller-daemon/tests/fixtures/` parse and narrow correctly. Schema-drift guard. +- **Sidebar grouping:** projects + sessions → expected grouped structure plus "Other". + +### Tier 2 — component (@testing-library/svelte) + +- `ChatInput`: `Cmd+Enter` sends; `Esc` interrupts when running, no-op when idle; disabled on `ended`. +- `MessageBlock`: each kind renders without throwing; `ToolCallBlock` toggles. +- `NewChatDialog`: submit disabled until valid; submit fires expected body. +- `DaemonEmptyState`: Retry calls `ping()`; on success, swaps out. + +### Tier 3 — integration against the daemon's fake agent (spine) + +Reuse `the-controller-daemon/tests/support/fake_agent` (scriptable stream-json binary): + +1. `pnpm test:integration` starts `the-controller-daemon` with `TCD_AGENT_CLAUDE_BINARY` → fake agent, `TCD_STATE_DIR` → tmpdir. +2. Playwright test (reuses existing e2e harness): + - Switch to chat mode, create chat in a test project. + - Send "hello" → assert user bubble, then streamed deltas, then finalized agent bubble. + - Assert `tool_call` renders collapsed; expand attaches `tool_result`. + - Kill daemon → assert `DaemonEmptyState`; restart + Retry → workspace returns. + - Reconnect with `?since` exercised when WS alone is dropped (daemon alive); assert no duplicates. + +### Tier 4 — real-Claude smoke (opt-in, local) + +Gated by `CHAT_SMOKE=1`. Not in CI. + +### Validation mapping (per CLAUDE.md) + +| Change | Test that fails if reverted | +|---|---| +| Register `chat` workspace mode | Playwright: "Space c switches to chat mode" | +| Event reducer | Unit: golden fixture | +| Delta streaming + finalize | Playwright: delta appears then resolves | +| Tool call inline collapsed + expand | Component: `ToolCallBlock` toggle | +| Inline Approve/Deny | Playwright with fake_agent emitting `waiting_for_tool_approval` | +| Reconnect with `?since` | Integration: kill WS mid-stream, assert no duplicates | +| `DaemonEmptyState` on connect failure | Component: ping fails → empty state | +| Group sessions by project cwd | Unit: grouping function | + +## Open questions + +None blocking. To revisit: + +- Exact virtualization strategy if transcripts grow large (start with plain scroll; measure before adding a dep). +- Whether to surface token_usage per-message, footer, or both. +- Whether Codex end-to-end needs a second integration pass (Claude is the primary target). + +## Next step + +Invoke the `the-controller-writing-plans` skill to produce a detailed implementation plan against this design. diff --git a/docs/plans/2026-04-22-chat-mode-plan.md b/docs/plans/2026-04-22-chat-mode-plan.md new file mode 100644 index 00000000..61cd7741 --- /dev/null +++ b/docs/plans/2026-04-22-chat-mode-plan.md @@ -0,0 +1,1689 @@ +# Chat Mode Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use the-controller-executing-plans to implement this plan task-by-task. + +**Goal:** Add a new `chat` workspace mode to the Tauri app that renders `the-controller-daemon` sessions as structured messages (user / agent / tool call / tool approval), alongside the existing `development` and `agents` modes. + +**Architecture:** The Svelte frontend talks directly to the daemon over HTTP + WebSocket (`127.0.0.1:4867`, bearer token from `~/.the-controller/daemon.token`). A single Tauri command `read_daemon_token` reads the token file. All daemon logic lives in `src/lib/daemon/` (types, client, WS stream, store). Chat UI lives in `src/lib/chat/`. The daemon is assumed to be running externally; if unreachable, a full-panel empty state is shown. Both Claude and Codex agents are supported. + +**Tech Stack:** Svelte 5 (runes), TypeScript, Tauri v2, Rust, Vitest, @testing-library/svelte, Playwright (existing e2e harness). Design doc: `docs/plans/2026-04-22-chat-mode-design.md` (commit `1e3d570`). Daemon reference: `~/projects/the-controller-daemon/` (README.md + `src/model.rs` + `docs/plans/2026-04-20-daemon-chat-architecture-design.md`). + +**Process rules:** Every task follows the CLAUDE.md **Definition / Constraints / Validation** structure — stated inline per task. Commit after every green task. Use `pnpm test -- --run ` for single-file unit tests (no watch mode). + +--- + +## Task 1: Extend `WorkspaceMode` type with `"chat"` + +**Definition:** Add `"chat"` to the `WorkspaceMode` union so the rest of the codebase can reference it. +**Constraints:** No default mode change — `development` stays the default. No new store. +**Validation:** Existing tests still pass; a new test for the type's constituent strings passes. + +**Files:** +- Modify: `src/lib/stores.ts:131-134` +- Test: `src/lib/stores.test.ts` (append) + +**Step 1: Write the failing test** + +```ts +// in src/lib/stores.test.ts +import { describe, it, expect } from "vitest"; +import type { WorkspaceMode } from "./stores"; + +describe("WorkspaceMode", () => { + it("accepts 'chat' as a valid value", () => { + const m: WorkspaceMode = "chat"; + expect(m).toBe("chat"); + }); +}); +``` + +**Step 2: Run test — expect fail** + +Run: `pnpm test -- --run src/lib/stores.test.ts` +Expected: TS compile error `Type '"chat"' is not assignable to type 'WorkspaceMode'`. + +**Step 3: Implement** + +Modify `src/lib/stores.ts:131-133`: +```ts +export type WorkspaceMode = + | "development" + | "agents" + | "chat"; +``` + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/stores.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/stores.ts src/lib/stores.test.ts +git commit -m "feat(chat): add 'chat' to WorkspaceMode union" +``` + +--- + +## Task 2: Register `chat` in the workspace mode picker + +**Definition:** The mode picker lists selectable modes. Add an entry with hotkey `c`. +**Constraints:** Keep the shape of existing entries. Label: "Chat". Position after "Agents". +**Validation:** Rendered component contains the new entry; existing snapshots / assertions unchanged. + +**Files:** +- Modify: `src/lib/WorkspaceModePicker.svelte:8-11` +- Test: `src/lib/__tests__/WorkspaceModePicker.test.ts` (create if absent) + +**Step 1: Write failing test** + +```ts +// src/lib/__tests__/WorkspaceModePicker.test.ts +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/svelte"; +import WorkspaceModePicker from "../WorkspaceModePicker.svelte"; + +describe("WorkspaceModePicker", () => { + it("lists chat with hotkey c", () => { + const { getByText } = render(WorkspaceModePicker); + expect(getByText("Chat")).toBeTruthy(); + // kbd adjacent to "Chat" shows "c" + const chat = getByText("Chat"); + const row = chat.closest(".picker-option")!; + expect(row.querySelector("kbd")!.textContent).toBe("c"); + }); +}); +``` + +**Step 2: Run — expect fail** + +Run: `pnpm test -- --run src/lib/__tests__/WorkspaceModePicker.test.ts` +Expected: FAIL — "Chat" not found. + +**Step 3: Implement** + +Modify `src/lib/WorkspaceModePicker.svelte:8-11`: +```ts + const modes: { key: string; id: WorkspaceMode; label: string }[] = [ + { key: "d", id: "development", label: "Development" }, + { key: "a", id: "agents", label: "Agents" }, + { key: "c", id: "chat", label: "Chat" }, + ]; +``` + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/__tests__/WorkspaceModePicker.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/WorkspaceModePicker.svelte src/lib/__tests__/WorkspaceModePicker.test.ts +git commit -m "feat(chat): register chat in workspace mode picker" +``` + +--- + +## Task 3: Handle `c` key in `HotkeyManager.handleWorkspaceModeKey` + +**Definition:** `Space` then `c` must switch the workspace to `chat`. +**Constraints:** Mirror the existing `d` / `a` branches exactly, including `focusForModeSwitch`. +**Validation:** A unit test for the handler (extracted or via HotkeyManager.test.ts) covers the new branch. + +**Files:** +- Modify: `src/lib/HotkeyManager.svelte:87-103` +- Modify: `src/lib/focus-helpers.ts:53-80` (add a `chat` branch) +- Test: `src/lib/focus-helpers.test.ts` (append) + +**Step 1: Write failing test** + +Append to `src/lib/focus-helpers.test.ts`: +```ts +describe("focusForModeSwitch — chat", () => { + it("preserves session focus when switching to chat", () => { + const projects = [{ id: "p1", sessions: [{ id: "s1", auto_worker_session: false }] }] as any; + const result = focusForModeSwitch( + { type: "session", sessionId: "s1", projectId: "p1" }, + "chat", + "s1", + projects, + ); + // v1: preserve current focus; ChatWorkspace decides what to do with it + expect(result).toEqual({ type: "session", sessionId: "s1", projectId: "p1" }); + }); +}); +``` + +**Step 2: Run — expect fail** + +Run: `pnpm test -- --run src/lib/focus-helpers.test.ts` +Expected: TS error if the `chat` branch doesn't satisfy the type, or test failure. + +**Step 3: Implement** + +(a) Modify `src/lib/HotkeyManager.svelte` inside `handleWorkspaceModeKey` (around line 100) — add: +```ts + if (key === "c") { + workspaceMode.set("chat"); + const newFocus = focusForModeSwitch(currentFocus, "chat", activeId, projectList); + if (newFocus !== currentFocus) focusTarget.set(newFocus); + return; + } +``` + +(b) `focus-helpers.ts` — no structural change needed; the function already falls through and returns `current`. But for type soundness, ensure the `WorkspaceMode` switch is exhaustive if a switch statement is ever used. + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/focus-helpers.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/HotkeyManager.svelte src/lib/focus-helpers.ts src/lib/focus-helpers.test.ts +git commit -m "feat(chat): handle Space+c to switch to chat mode" +``` + +--- + +## Task 4: Add `read_daemon_token` Tauri command + +**Definition:** A Rust-side command reads `~/.the-controller/daemon.token` and returns its contents. Returns a structured error if the file is missing or unreadable. +**Constraints:** No caching — token file is small and rarely read. Path comes from `$TCD_STATE_DIR` env var if set (matches daemon default), else `~/.the-controller/`. Trim whitespace on return. +**Validation:** Rust unit test in `commands.rs` covers present / absent / unreadable. + +**Files:** +- Create: `src-tauri/src/commands/daemon.rs` +- Modify: `src-tauri/src/commands.rs` (add `mod daemon;`) +- Modify: `src-tauri/src/lib.rs:61-118` (register command) + +**Step 1: Write failing test** + +Create `src-tauri/src/commands/daemon.rs` with only a test for now: +```rust +use std::path::PathBuf; + +pub(crate) fn daemon_token_path() -> PathBuf { + if let Ok(dir) = std::env::var("TCD_STATE_DIR") { + return PathBuf::from(dir).join("daemon.token"); + } + let home = std::env::var("HOME").unwrap_or_default(); + PathBuf::from(home).join(".the-controller").join("daemon.token") +} + +pub(crate) fn read_token_from(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path) + .map_err(|e| format!("read daemon token at {}: {}", path.display(), e))?; + let s = String::from_utf8(bytes).map_err(|e| format!("token not utf-8: {}", e))?; + Ok(s.trim().to_string()) +} + +#[tauri::command] +pub async fn read_daemon_token() -> Result { + let path = daemon_token_path(); + tokio::task::spawn_blocking(move || read_token_from(&path)) + .await + .map_err(|e| format!("join error: {}", e))? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn reads_and_trims_token() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("daemon.token"); + let mut f = std::fs::File::create(&p).unwrap(); + writeln!(f, "abc123").unwrap(); + assert_eq!(read_token_from(&p).unwrap(), "abc123"); + } + + #[test] + fn missing_token_returns_err() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("daemon.token"); + let err = read_token_from(&p).unwrap_err(); + assert!(err.contains("daemon.token"), "got: {}", err); + } +} +``` + +Register `tempfile` if not already a dev-dep (check `src-tauri/Cargo.toml`; likely already used). + +**Step 2: Run — expect fail initially, then pass** + +Run: `cd src-tauri && cargo test commands::daemon::tests -- --nocapture` +Expected: compile errors until `mod daemon;` is added. + +**Step 3: Wire the module** + +Modify `src-tauri/src/commands.rs` around the existing `mod github;` / `mod media;` lines: +```rust +mod daemon; +pub use daemon::read_daemon_token; +``` + +Modify `src-tauri/src/lib.rs:61-118` — add `commands::read_daemon_token,` to the `tauri::generate_handler![...]` list. + +**Step 4: Run — expect pass** + +Run: `cd src-tauri && cargo test commands::daemon::tests` +Expected: `test result: ok. 2 passed`. + +**Step 5: Commit** + +```bash +git add src-tauri/src/commands.rs src-tauri/src/commands/daemon.rs src-tauri/src/lib.rs +git commit -m "feat(chat): add read_daemon_token Tauri command" +``` + +--- + +## Task 5: Daemon wire types in TypeScript + +**Definition:** Mirror the daemon's `model.rs` + event schema in `src/lib/daemon/types.ts`. +**Constraints:** Exactly match the daemon's JSON shape — `snake_case` fields, union strings. No runtime validation for v1 (types only). +**Validation:** A round-trip test parses a fixture JSON file captured from the daemon's test fixtures. + +**Files:** +- Create: `src/lib/daemon/types.ts` +- Create: `src/lib/daemon/__fixtures__/events.json` — copy representative outbox/inbox/system events from `~/projects/the-controller-daemon/tests/fixtures/` (pick 5–10 diverse events) +- Create: `src/lib/daemon/types.test.ts` + +**Step 1: Write failing test** + +```ts +// src/lib/daemon/types.test.ts +import { describe, it, expect } from "vitest"; +import fixture from "./__fixtures__/events.json"; +import type { EventRecord, OutboxEvent, InboxEvent, SystemEvent } from "./types"; + +describe("daemon event types", () => { + it("parses fixture events without type errors", () => { + const events = fixture as EventRecord[]; + expect(events.length).toBeGreaterThan(0); + for (const e of events) { + expect(["inbox", "outbox", "system"]).toContain(e.channel); + expect(typeof e.seq).toBe("number"); + } + }); + + it("narrows outbox agent_text payload", () => { + const e: OutboxEvent = { + kind: "agent_text", + payload: { message_id: "m1", block_id: "b1", text: "hello" }, + }; + expect(e.payload.text).toBe("hello"); + }); +}); +``` + +**Step 2: Run — expect fail** + +Run: `pnpm test -- --run src/lib/daemon/types.test.ts` +Expected: fail — module not found. + +**Step 3: Implement** + +Create `src/lib/daemon/types.ts`: +```ts +export type Agent = "claude" | "codex"; +export type SessionStatus = "starting" | "running" | "interrupted" | "ended" | "failed"; +export type Channel = "inbox" | "outbox" | "system"; + +export interface DaemonSession { + id: string; + label: string; + agent: Agent; + cwd: string; + args: string[]; + status: SessionStatus; + native_session_id: string | null; + pid: number | null; + created_at: number; + updated_at: number; + ended_at: number | null; + end_reason: string | null; +} + +export interface EventRecord { + session_id: string; + seq: number; + channel: Channel; + kind: string; + payload: unknown; + created_at: number; + applied_at: number | null; +} + +export type OutboxEvent = + | { kind: "agent_text"; payload: { message_id: string; block_id: string; text: string; role?: string } } + | { kind: "agent_text_delta"; payload: { message_id: string; block_id: string; delta: string; role?: string } } + | { kind: "agent_thinking"; payload: { message_id: string; block_id: string; text: string } } + | { kind: "tool_call"; payload: { call_id: string; tool: string; input: unknown } } + | { kind: "tool_result"; payload: { call_id: string; output: unknown; is_error: boolean } } + | { kind: "token_usage"; payload: { input: number; output: number; cache_read: number; cache_write: number } } + | { kind: "error"; payload: { code: string; message: string; detail?: unknown } }; + +export type InboxEvent = + | { kind: "user_text"; payload: { text: string } } + | { kind: "interrupt"; payload: {} } + | { kind: "tool_approval"; payload: { call_id: string; approved: boolean; reason?: string } }; + +export type StatusState = "starting" | "idle" | "working" | "waiting_for_tool_approval" | "failed"; + +export type SystemEvent = + | { kind: "session_started"; payload: { agent: Agent; cwd: string; args: string[] } } + | { kind: "session_ended"; payload: { end_reason: string; exit_code?: number; signal?: string } } + | { kind: "session_interrupted"; payload: { reason: string } } + | { kind: "session_resumed"; payload: { native_session_id: string } } + | { kind: "agent_crashed"; payload: { exit_code?: number; signal?: string; last_stderr_tail?: string } } + | { kind: "status_changed"; payload: { state: StatusState; idle_ms?: number } }; +``` + +Populate `events.json` by running the daemon with fake agent and piping `curl /sessions//messages?since=0` into the file. If the daemon is not running, handcraft a minimal array of 5 events covering one of each channel. + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/daemon/types.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/daemon/types.ts src/lib/daemon/types.test.ts src/lib/daemon/__fixtures__/events.json +git commit -m "feat(chat): add daemon wire types" +``` + +--- + +## Task 6: HTTP client (`client.ts`) + +**Definition:** Typed fetch wrapper over the daemon's HTTP API with bearer auth. Only endpoints needed for v1. +**Constraints:** No third-party HTTP lib; use `fetch`. Token passed in explicitly per call — no global. Throw a typed `DaemonHttpError` on non-2xx with status + body for error-handling paths to discriminate. +**Validation:** Unit tests using `fetch` mocked via `vi.stubGlobal` cover happy paths + 401/404/409/503. + +**Files:** +- Create: `src/lib/daemon/client.ts` +- Create: `src/lib/daemon/client.test.ts` + +**Step 1: Write failing test** + +```ts +// src/lib/daemon/client.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DaemonClient, DaemonHttpError } from "./client"; + +function mockFetch(responses: Array<{ status: number; body: unknown }>) { + const queue = [...responses]; + return vi.fn(async () => { + const r = queue.shift()!; + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + json: async () => r.body, + text: async () => JSON.stringify(r.body), + } as any; + }); +} + +describe("DaemonClient", () => { + beforeEach(() => vi.restoreAllMocks()); + + it("listSessions sets Authorization header and returns array", async () => { + const fetchMock = mockFetch([{ status: 200, body: [{ id: "s1", label: "x", agent: "claude" }] }]); + vi.stubGlobal("fetch", fetchMock); + const c = new DaemonClient("http://127.0.0.1:4867", "TOK"); + const sessions = await c.listSessions(); + expect(sessions[0].id).toBe("s1"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://127.0.0.1:4867/sessions"); + expect((init as any).headers.Authorization).toBe("Bearer TOK"); + }); + + it("throws DaemonHttpError on 404", async () => { + vi.stubGlobal("fetch", mockFetch([{ status: 404, body: { error: "not found" } }])); + const c = new DaemonClient("http://127.0.0.1:4867", "TOK"); + await expect(c.getSession("missing")).rejects.toMatchObject({ + name: "DaemonHttpError", + status: 404, + }); + }); +}); +``` + +**Step 2: Run — expect fail (module not found)** + +**Step 3: Implement** + +```ts +// src/lib/daemon/client.ts +import type { DaemonSession, EventRecord, Agent, Channel } from "./types"; + +export class DaemonHttpError extends Error { + name = "DaemonHttpError"; + constructor(public status: number, public body: string, message: string) { + super(message); + } +} + +export interface CreateSessionRequest { + agent: Agent; + cwd: string; + args?: string[]; + initial_prompt?: string; +} + +export interface SendMessageRequest { + kind: "user_text" | "interrupt" | "tool_approval"; + text?: string; + call_id?: string; + approved?: boolean; + reason?: string; +} + +export class DaemonClient { + constructor(private baseUrl: string, private token: string) {} + + private async call(path: string, init?: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new DaemonHttpError(res.status, body, `daemon ${res.status} on ${path}: ${body}`); + } + // 204 has no body + if (res.status === 204) return undefined as unknown as T; + return res.json() as Promise; + } + + listSessions(): Promise { + return this.call("/sessions"); + } + getSession(id: string): Promise { + return this.call(`/sessions/${id}`); + } + createSession(req: CreateSessionRequest): Promise<{ id: string; label: string }> { + return this.call("/sessions", { method: "POST", body: JSON.stringify(req) }); + } + deleteSession(id: string): Promise { + return this.call(`/sessions/${id}`, { method: "DELETE" }); + } + sendMessage(id: string, req: SendMessageRequest): Promise<{ seq: number }> { + // payload shape mirrors the daemon's inbox kinds + const body = + req.kind === "user_text" ? { kind: "user_text", text: req.text } : + req.kind === "interrupt" ? { kind: "interrupt" } : + { kind: "tool_approval", call_id: req.call_id, approved: req.approved, reason: req.reason }; + return this.call(`/sessions/${id}/messages`, { method: "POST", body: JSON.stringify(body) }); + } + readEvents(id: string, since = 0, channels?: Channel[]): Promise { + const q = new URLSearchParams(); + q.set("since", String(since)); + if (channels && channels.length) q.set("channels", channels.join(",")); + return this.call(`/sessions/${id}/messages?${q.toString()}`); + } + + wsUrl(id: string, since = 0, channels?: Channel[]): string { + const base = this.baseUrl.replace(/^http/, "ws"); + const q = new URLSearchParams(); + q.set("since", String(since)); + if (channels && channels.length) q.set("channels", channels.join(",")); + q.set("token", this.token); // fallback if Authorization header isn't supported on WS; actual auth is via header + return `${base}/sessions/${id}/stream?${q.toString()}`; + } + + get bearer(): string { return this.token; } +} +``` + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/daemon/client.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/daemon/client.ts src/lib/daemon/client.test.ts +git commit -m "feat(chat): add typed DaemonClient" +``` + +--- + +## Task 7: Event reducer (pure function) + +**Definition:** A pure `reduce(events, deltaState) => renderable blocks`. Handles: +- finalized `agent_text` finalizes a block +- `agent_text_delta` accumulates into in-progress +- `tool_result` joined to `tool_call` by `call_id` +- dedupe by `(seq)` +**Constraints:** No Svelte imports. Operate on plain data for easy unit testing. +**Validation:** Golden fixture tests; revert implementation → test fails. + +**Files:** +- Create: `src/lib/daemon/reducer.ts` +- Create: `src/lib/daemon/reducer.test.ts` + +**Step 1: Write failing test** + +```ts +// src/lib/daemon/reducer.test.ts +import { describe, it, expect } from "vitest"; +import { reduceTranscript, emptyTranscript } from "./reducer"; +import type { EventRecord } from "./types"; + +const makeEvt = (seq: number, channel: "inbox"|"outbox"|"system", kind: string, payload: any): EventRecord => ({ + session_id: "s", seq, channel, kind, payload, created_at: seq, applied_at: null, +}); + +describe("reduceTranscript", () => { + it("accumulates deltas, drops in-progress on finalize", () => { + let t = emptyTranscript(); + t = reduceTranscript(t, makeEvt(1, "outbox", "agent_text_delta", { message_id: "m1", block_id: "b1", delta: "Hel" })); + t = reduceTranscript(t, makeEvt(2, "outbox", "agent_text_delta", { message_id: "m1", block_id: "b1", delta: "lo" })); + expect(t.inProgressBlocks.get("b1")).toBe("Hello"); + t = reduceTranscript(t, makeEvt(3, "outbox", "agent_text", { message_id: "m1", block_id: "b1", text: "Hello" })); + expect(t.inProgressBlocks.has("b1")).toBe(false); + expect(t.events.at(-1)?.kind).toBe("agent_text"); + }); + + it("dedupes repeated (seq)", () => { + let t = emptyTranscript(); + const e = makeEvt(1, "inbox", "user_text", { text: "hi" }); + t = reduceTranscript(t, e); + t = reduceTranscript(t, e); + expect(t.events.length).toBe(1); + }); + + it("tracks status_changed", () => { + let t = emptyTranscript(); + t = reduceTranscript(t, makeEvt(1, "system", "status_changed", { state: "working" })); + expect(t.statusState).toBe("working"); + }); +}); +``` + +**Step 2: Run — expect fail** + +**Step 3: Implement** + +```ts +// src/lib/daemon/reducer.ts +import type { EventRecord, StatusState } from "./types"; + +export interface TranscriptState { + events: EventRecord[]; + lastSeq: number; + inProgressBlocks: Map; + statusState: StatusState | null; + tokenUsage: { input: number; output: number; cache_read: number; cache_write: number } | null; + seenSeq: Set; +} + +export function emptyTranscript(): TranscriptState { + return { + events: [], + lastSeq: 0, + inProgressBlocks: new Map(), + statusState: null, + tokenUsage: null, + seenSeq: new Set(), + }; +} + +export function reduceTranscript(prev: TranscriptState, e: EventRecord): TranscriptState { + if (prev.seenSeq.has(e.seq)) return prev; + const seenSeq = new Set(prev.seenSeq); seenSeq.add(e.seq); + const lastSeq = Math.max(prev.lastSeq, e.seq); + let inProgressBlocks = prev.inProgressBlocks; + let statusState = prev.statusState; + let tokenUsage = prev.tokenUsage; + let events = prev.events; + + if (e.channel === "outbox" && e.kind === "agent_text_delta") { + const p = e.payload as { block_id: string; delta: string }; + inProgressBlocks = new Map(inProgressBlocks); + inProgressBlocks.set(p.block_id, (inProgressBlocks.get(p.block_id) ?? "") + p.delta); + // deltas are not persisted; do not append to events + return { ...prev, seenSeq, lastSeq, inProgressBlocks }; + } + if (e.channel === "outbox" && e.kind === "agent_text") { + const p = e.payload as { block_id: string }; + if (inProgressBlocks.has(p.block_id)) { + inProgressBlocks = new Map(inProgressBlocks); + inProgressBlocks.delete(p.block_id); + } + events = [...events, e]; + return { ...prev, seenSeq, lastSeq, inProgressBlocks, events }; + } + if (e.channel === "outbox" && e.kind === "token_usage") { + tokenUsage = e.payload as any; + } + if (e.channel === "system" && e.kind === "status_changed") { + statusState = (e.payload as { state: StatusState }).state; + } + + events = [...events, e]; + return { ...prev, seenSeq, lastSeq, events, inProgressBlocks, statusState, tokenUsage }; +} +``` + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/daemon/reducer.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/daemon/reducer.ts src/lib/daemon/reducer.test.ts +git commit -m "feat(chat): add pure event reducer" +``` + +--- + +## Task 8: Daemon store + bootstrap + +**Definition:** A module-level Svelte 5 `$state` graph exposing: `token`, `reachable`, `sessions` Map, `transcripts` Map. `bootstrap()` reads the token via `read_daemon_token` and pings `GET /sessions`. Exposes `pingDaemon()`, `loadSessions()`. +**Constraints:** Svelte runes, not writables. WS handling is separate (Task 9). No WS here. +**Validation:** Test `bootstrap` by stubbing `command` and `fetch`; assert `reachable` flips correctly. + +**Files:** +- Create: `src/lib/daemon/store.ts` +- Create: `src/lib/daemon/store.test.ts` + +**Step 1: Write failing test** + +```ts +// src/lib/daemon/store.test.ts +import { describe, it, expect, vi } from "vitest"; + +vi.mock("$lib/backend", () => ({ + command: vi.fn(async (cmd: string) => { + if (cmd === "read_daemon_token") return "TOK"; + throw new Error("unexpected command " + cmd); + }), + listen: () => () => {}, +})); + +describe("daemon store bootstrap", () => { + it("sets reachable=true on successful ping", async () => { + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => [] } as any)); + vi.stubGlobal("fetch", fetchMock); + const { daemonStore, bootstrap } = await import("./store"); + await bootstrap(); + expect(daemonStore.reachable).toBe(true); + expect(daemonStore.token).toBe("TOK"); + }); + + it("sets reachable=false when ping fails", async () => { + vi.resetModules(); + vi.doMock("$lib/backend", () => ({ + command: vi.fn(async () => "TOK"), + listen: () => () => {}, + })); + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("connect refused"); })); + const { daemonStore, bootstrap } = await import("./store"); + await bootstrap(); + expect(daemonStore.reachable).toBe(false); + }); +}); +``` + +**Step 2: Run — expect fail** + +**Step 3: Implement** + +```ts +// src/lib/daemon/store.ts +import { command } from "$lib/backend"; +import { DaemonClient } from "./client"; +import type { DaemonSession } from "./types"; +import { emptyTranscript, type TranscriptState } from "./reducer"; + +const BASE_URL = "http://127.0.0.1:4867"; + +interface StoreState { + token: string | null; + reachable: boolean; + client: DaemonClient | null; + sessions: Map; + transcripts: Map; + activeSessionId: string | null; +} + +export const daemonStore = $state({ + token: null, + reachable: false, + client: null, + sessions: new Map(), + transcripts: new Map(), + activeSessionId: null, +}); + +export async function bootstrap(): Promise { + try { + const token = await command("read_daemon_token"); + daemonStore.token = token; + daemonStore.client = new DaemonClient(BASE_URL, token); + } catch (e) { + daemonStore.reachable = false; + daemonStore.token = null; + daemonStore.client = null; + return; + } + await pingDaemon(); +} + +export async function pingDaemon(): Promise { + if (!daemonStore.client) { daemonStore.reachable = false; return; } + try { + await daemonStore.client.listSessions(); + daemonStore.reachable = true; + } catch { + daemonStore.reachable = false; + } +} + +export async function loadSessions(): Promise { + if (!daemonStore.client) return; + const list = await daemonStore.client.listSessions(); + const map = new Map(); + for (const s of list) { + map.set(s.id, s); + if (!daemonStore.transcripts.has(s.id)) { + daemonStore.transcripts.set(s.id, emptyTranscript()); + } + } + daemonStore.sessions = map; +} +``` + +Note: `$state` at module top-level must be used inside `.svelte.ts` files in Svelte 5. Rename to `store.svelte.ts`. + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/daemon/store.test.ts` +Expected: PASS. If `$state` isn't allowed at module top-level in `.ts`, rename to `src/lib/daemon/store.svelte.ts` and update imports. + +**Step 5: Commit** + +```bash +git add src/lib/daemon/store.svelte.ts src/lib/daemon/store.test.ts +git commit -m "feat(chat): add daemon store + bootstrap" +``` + +--- + +## Task 9: WS stream with reconnect + +**Definition:** `openStream(sessionId)` manages one WebSocket per session. Feeds incoming events into `reduceTranscript` via the store. Reconnect with exponential backoff cap 10s, resume with `?since=`. +**Constraints:** One WS per active session (close on inactive). Auth header via `Authorization: Bearer ` in the WS upgrade — if browsers disallow, fall back to the `?token=` query param from `wsUrl`. Start with `?token=` for v1; revisit. +**Validation:** Unit test with a mock `WebSocket` global; simulate close + reconnect, assert URL contains correct `since`. + +**Files:** +- Create: `src/lib/daemon/stream.ts` +- Create: `src/lib/daemon/stream.test.ts` + +**Step 1: Write failing test** + +```ts +// src/lib/daemon/stream.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + static clear() { this.instances = []; } + onopen: ((e: any) => void) | null = null; + onmessage: ((e: any) => void) | null = null; + onclose: ((e: any) => void) | null = null; + onerror: ((e: any) => void) | null = null; + readyState = 0; + constructor(public url: string) { MockWebSocket.instances.push(this); } + send() {} + close() { this.readyState = 3; this.onclose?.({ code: 1006 }); } +} + +describe("stream", () => { + beforeEach(() => { + vi.useFakeTimers(); + MockWebSocket.clear(); + vi.stubGlobal("WebSocket", MockWebSocket as any); + }); + + it("reconnects with since=", async () => { + vi.resetModules(); + vi.doMock("$lib/backend", () => ({ command: vi.fn(async () => "TOK"), listen: () => () => {} })); + const { daemonStore } = await import("./store"); + daemonStore.token = "TOK"; + daemonStore.client = { wsUrl: (id: string, since: number) => `ws://x/sessions/${id}/stream?since=${since}&token=TOK` } as any; + daemonStore.transcripts.set("s1", { events: [], lastSeq: 5, inProgressBlocks: new Map(), statusState: null, tokenUsage: null, seenSeq: new Set() }); + const { openStream } = await import("./stream"); + const handle = openStream("s1"); + expect(MockWebSocket.instances.length).toBe(1); + MockWebSocket.instances[0].close(); + await vi.advanceTimersByTimeAsync(1100); + expect(MockWebSocket.instances[1].url).toContain("since=5"); + handle.close(); + }); +}); +``` + +**Step 2: Run — expect fail** + +**Step 3: Implement** + +```ts +// src/lib/daemon/stream.ts +import { daemonStore } from "./store.svelte"; +import { reduceTranscript } from "./reducer"; +import type { EventRecord } from "./types"; + +export interface StreamHandle { + close(): void; +} + +export function openStream(sessionId: string): StreamHandle { + let closed = false; + let ws: WebSocket | null = null; + let attempt = 0; + + function connect() { + if (closed) return; + const client = daemonStore.client; + if (!client) return; + const t = daemonStore.transcripts.get(sessionId); + const since = t?.lastSeq ?? 0; + const url = client.wsUrl(sessionId, since); + ws = new WebSocket(url); + ws.onmessage = (ev) => { + try { + const evt = JSON.parse(ev.data) as EventRecord; + const prev = daemonStore.transcripts.get(sessionId) ?? undefined; + const next = reduceTranscript(prev ?? { events: [], lastSeq: 0, inProgressBlocks: new Map(), statusState: null, tokenUsage: null, seenSeq: new Set() }, evt); + daemonStore.transcripts.set(sessionId, next); + } catch {} + }; + ws.onclose = () => { + if (closed) return; + const delay = Math.min(10_000, 500 * 2 ** attempt); + attempt += 1; + setTimeout(connect, delay); + }; + ws.onopen = () => { attempt = 0; }; + } + + connect(); + return { + close() { + closed = true; + ws?.close(); + }, + }; +} +``` + +**Step 4: Run — expect pass** + +Run: `pnpm test -- --run src/lib/daemon/stream.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/daemon/stream.ts src/lib/daemon/stream.test.ts +git commit -m "feat(chat): WS stream with reconnect and resume-from-seq" +``` + +--- + +## Task 10: `ChatWorkspace.svelte` + `DaemonEmptyState.svelte` + +**Definition:** Root chat component that renders either the empty state (unreachable) or a placeholder chat area (filled in later). +**Constraints:** On mount, call `bootstrap()`. Render `` when `!reachable`. +**Validation:** Component test — stub `bootstrap` to set `reachable=false`, assert empty state renders. + +**Files:** +- Create: `src/lib/chat/ChatWorkspace.svelte` +- Create: `src/lib/chat/DaemonEmptyState.svelte` +- Create: `src/lib/chat/ChatWorkspace.test.ts` + +**Step 1: Write failing test** + +```ts +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent } from "@testing-library/svelte"; + +vi.mock("../daemon/store.svelte", async () => { + const actual = await vi.importActual("../daemon/store.svelte"); + return { + ...actual, + bootstrap: vi.fn(async () => {}), + pingDaemon: vi.fn(async () => {}), + }; +}); + +import ChatWorkspace from "./ChatWorkspace.svelte"; +import { daemonStore } from "../daemon/store.svelte"; + +describe("ChatWorkspace", () => { + it("renders empty state when daemon unreachable", async () => { + daemonStore.reachable = false; + const { findByText } = render(ChatWorkspace); + expect(await findByText(/Daemon not running/)).toBeTruthy(); + }); + + it("shows Retry button that calls pingDaemon", async () => { + daemonStore.reachable = false; + const { getByText } = render(ChatWorkspace); + const btn = getByText("Retry"); + await fireEvent.click(btn); + const { pingDaemon } = await import("../daemon/store.svelte"); + expect(pingDaemon).toHaveBeenCalled(); + }); +}); +``` + +**Step 2: Run — expect fail** + +**Step 3: Implement** + +```svelte + + + +
+

Daemon not running

+

Start it with:

+
./target/release/the-controller-daemon
+

Expected token: ~/.the-controller/daemon.token

+ +
+ + +``` + +```svelte + + + +{#if !daemonStore.reachable} + +{:else} +
+

Chat mode (placeholder)

+
+{/if} + + +``` + +**Step 4: Wire into App.svelte** + +Modify `src/App.svelte:375-379`: +```svelte + {#if workspaceModeState.current === "agents"} + + {:else if workspaceModeState.current === "chat"} + + {:else} + + {/if} +``` + +Add import at `src/App.svelte` top alongside other component imports: +```ts +import ChatWorkspace from "./lib/chat/ChatWorkspace.svelte"; +``` + +**Step 5: Run — expect pass** + +Run: `pnpm test -- --run src/lib/chat/ChatWorkspace.test.ts` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add src/lib/chat/ChatWorkspace.svelte src/lib/chat/DaemonEmptyState.svelte src/lib/chat/ChatWorkspace.test.ts src/App.svelte +git commit -m "feat(chat): mount ChatWorkspace with DaemonEmptyState" +``` + +--- + +## Task 11: `ChatSessionList.svelte` + Sidebar integration + +**Definition:** In chat mode, the sidebar shows daemon sessions grouped by project (match by `cwd === project.repo_path`), plus an "Other" group for unmatched sessions, and a "+ New chat" row per project. +**Constraints:** Reuse the existing `Sidebar.svelte` container. Branch on `currentMode === "chat"` the way it already branches for `agents`. Pure grouping logic extracted to `src/lib/daemon/grouping.ts` for unit testing. +**Validation:** Grouping unit test + a component render test that "Other" group appears when a session cwd doesn't match. + +**Files:** +- Create: `src/lib/daemon/grouping.ts` +- Create: `src/lib/daemon/grouping.test.ts` +- Create: `src/lib/chat/ChatSessionList.svelte` +- Modify: `src/lib/Sidebar.svelte` (branch for chat mode) + +**Step 1: Failing test (grouping)** + +```ts +// src/lib/daemon/grouping.test.ts +import { describe, it, expect } from "vitest"; +import { groupSessionsByProject } from "./grouping"; + +describe("groupSessionsByProject", () => { + it("buckets sessions by project.repo_path; unmatched into Other", () => { + const projects = [{ id: "p1", name: "A", repo_path: "/tmp/a" }, { id: "p2", name: "B", repo_path: "/tmp/b" }] as any; + const sessions = [ + { id: "s1", cwd: "/tmp/a" }, { id: "s2", cwd: "/tmp/a" }, { id: "s3", cwd: "/other" }, + ] as any; + const groups = groupSessionsByProject(projects, sessions); + expect(groups.byProject.get("p1")!.map((s: any) => s.id)).toEqual(["s1", "s2"]); + expect(groups.byProject.get("p2")).toEqual([]); + expect(groups.other.map((s: any) => s.id)).toEqual(["s3"]); + }); +}); +``` + +**Step 2: Implement** + +```ts +// src/lib/daemon/grouping.ts +import type { DaemonSession } from "./types"; +import type { Project } from "$lib/stores"; + +export interface SessionGroups { + byProject: Map; + other: DaemonSession[]; +} + +export function groupSessionsByProject(projects: Project[], sessions: DaemonSession[]): SessionGroups { + const byProject = new Map(); + for (const p of projects) byProject.set(p.id, []); + const pathToId = new Map(projects.map(p => [p.repo_path, p.id])); + const other: DaemonSession[] = []; + for (const s of sessions) { + const pid = pathToId.get(s.cwd); + if (pid) byProject.get(pid)!.push(s); + else other.push(s); + } + return { byProject, other }; +} +``` + +**Step 3: ChatSessionList** + +```svelte + + + +
+ {#each projectList as p} +
{p.name}
+ {#each groups.byProject.get(p.id) ?? [] as s (s.id)} + + {/each} + + {/each} + {#if groups.other.length} +
Other
+ {#each groups.other as s (s.id)} + + {/each} + {/if} +
+ + +``` + +**Step 4: Sidebar integration** + +Modify `src/lib/Sidebar.svelte` — find the `{#if currentMode === "agents"}` branch and add a peer for chat mode (or refactor into three branches). Mount `` passing handlers that set `daemonStore.activeSessionId` and open the new chat dialog (Task 12 wires these up). + +**Step 5: Run tests** + +Run: `pnpm test -- --run src/lib/daemon/grouping.test.ts` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add src/lib/daemon/grouping.ts src/lib/daemon/grouping.test.ts src/lib/chat/ChatSessionList.svelte src/lib/Sidebar.svelte +git commit -m "feat(chat): sidebar session list grouped by project" +``` + +--- + +## Task 12: `NewChatDialog.svelte` (create session flow) + +**Definition:** Modal invoked from "+ New chat". Fields: agent (`claude` | `codex`) dropdown, optional initial prompt. On submit, calls `client.createSession({ agent, cwd: project.repo_path, initial_prompt })`. +**Constraints:** Submit disabled until agent selected. On success, close and set `daemonStore.activeSessionId` to the new id. On 422 error, show inline error ("Agent binary not configured on daemon"). +**Validation:** Component test: submit fires expected payload; 422 shows inline error. + +**Files:** +- Create: `src/lib/chat/NewChatDialog.svelte` +- Create: `src/lib/chat/NewChatDialog.test.ts` + +**Step 1: Failing test** + +```ts +// src/lib/chat/NewChatDialog.test.ts +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent } from "@testing-library/svelte"; + +const createSession = vi.fn(); +vi.mock("../daemon/store.svelte", () => ({ + daemonStore: { client: { createSession }, activeSessionId: null } as any, +})); + +import NewChatDialog from "./NewChatDialog.svelte"; + +describe("NewChatDialog", () => { + it("submits agent + cwd + initial prompt", async () => { + createSession.mockResolvedValueOnce({ id: "s1", label: "session-1" }); + const onClose = vi.fn(); + const { getByLabelText, getByText } = render(NewChatDialog, { projectId: "p1", projectCwd: "/tmp/a", onClose }); + await fireEvent.change(getByLabelText("Agent"), { target: { value: "claude" } }); + await fireEvent.change(getByLabelText("Initial prompt"), { target: { value: "hi" } }); + await fireEvent.click(getByText("Create")); + expect(createSession).toHaveBeenCalledWith({ agent: "claude", cwd: "/tmp/a", initial_prompt: "hi" }); + expect(onClose).toHaveBeenCalled(); + }); +}); +``` + +**Step 2: Implement** + +```svelte + + + + + + +``` + +Then wire the `onNewChat` handler in `ChatWorkspace.svelte` to render this dialog with the selected project's `repo_path`. + +**Step 3: Run tests** + +Run: `pnpm test -- --run src/lib/chat/NewChatDialog.test.ts` +Expected: PASS. + +**Step 4: Commit** + +```bash +git add src/lib/chat/NewChatDialog.svelte src/lib/chat/NewChatDialog.test.ts src/lib/chat/ChatWorkspace.svelte +git commit -m "feat(chat): new chat dialog with agent selector" +``` + +--- + +## Task 13: `ChatView.svelte` + `Transcript.svelte` scaffolding + +**Definition:** When a session is active, render header + scrollable transcript + input area. On mount, hydrate events via `client.readEvents(sid, 0)` and open a WS stream. Tear down on unmount. +**Constraints:** Virtualization deferred — plain scroll container for v1. The transcript derives its block list from `TranscriptState`. Messages render via `MessageBlock` (Task 14+). Header shows `label · agent · status`. +**Validation:** Component test: initial `readEvents` response is applied; a push via WS stream appears. + +**Files:** +- Create: `src/lib/chat/ChatView.svelte` +- Create: `src/lib/chat/Transcript.svelte` +- Create: `src/lib/chat/ChatView.test.ts` + +**Step 1: Failing test** + +```ts +import { describe, it, expect, vi } from "vitest"; +import { render, waitFor } from "@testing-library/svelte"; + +const readEvents = vi.fn(async () => [ + { session_id: "s1", seq: 1, channel: "outbox", kind: "agent_text", payload: { block_id: "b1", message_id: "m1", text: "hello" }, created_at: 1, applied_at: null }, +]); +vi.mock("../daemon/store.svelte", () => ({ + daemonStore: { + client: { readEvents, wsUrl: () => "ws://x" } as any, + sessions: new Map([["s1", { id: "s1", label: "Chat 1", agent: "claude", status: "running" }]]), + transcripts: new Map(), + activeSessionId: "s1", + }, +})); +vi.mock("../daemon/stream", () => ({ openStream: vi.fn(() => ({ close: vi.fn() })) })); + +import ChatView from "./ChatView.svelte"; + +describe("ChatView", () => { + it("hydrates from readEvents and renders the agent text", async () => { + const { findByText } = render(ChatView, { sessionId: "s1" }); + expect(await findByText("hello")).toBeTruthy(); + }); +}); +``` + +**Step 2: Implement** + +```svelte + + + +{#if session} +
+
+ {session.label} + {session.agent} + {session.status} +
+ + +
+{:else} +

Session not found.

+{/if} + + +``` + +```svelte + + + +
+ {#each transcript.events as e (e.seq)} + {#if e.channel === "outbox" && e.kind === "agent_text"} +
{(e.payload as any).text}
+ {:else if e.channel === "inbox" && e.kind === "user_text"} +
{(e.payload as any).text}
+ {/if} + {/each} + {#each [...transcript.inProgressBlocks.entries()] as [id, text] (id)} +
{text}
+ {/each} +
+ + +``` + +**Step 3: Run** + +Run: `pnpm test -- --run src/lib/chat/ChatView.test.ts` +Expected: PASS. + +**Step 4: Commit** + +```bash +git add src/lib/chat/ChatView.svelte src/lib/chat/Transcript.svelte src/lib/chat/ChatView.test.ts +git commit -m "feat(chat): ChatView + Transcript scaffolding" +``` + +--- + +## Task 14: `MessageBlock.svelte` dispatch + `UserMessage` / `AgentMessage` with markdown + +**Definition:** Replace the inline branches in `Transcript.svelte` with a `` that dispatches on kind. `AgentMessage` renders markdown via `src/lib/markdown.ts`. +**Constraints:** Keep delta rendering (from `inProgressBlocks`) separate from finalized blocks — deltas are plain text until finalized, then re-render as markdown. +**Validation:** Component test: markdown headings/code render in AgentMessage. + +**Files:** +- Create: `src/lib/chat/MessageBlock.svelte` +- Create: `src/lib/chat/UserMessage.svelte` +- Create: `src/lib/chat/AgentMessage.svelte` +- Modify: `src/lib/chat/Transcript.svelte` +- Create: `src/lib/chat/AgentMessage.test.ts` + +Code omitted for brevity — implement straightforwardly using existing `markdown.ts`: +```svelte + + +
{@html renderMarkdown(text)}
+``` + +**Validation test** (`AgentMessage.test.ts`): +```ts +it("renders inline code", async () => { + const { container } = render(AgentMessage, { text: "Run `ls`" }); + expect(container.querySelector("code")?.textContent).toBe("ls"); +}); +``` + +**Commit:** +```bash +git commit -m "feat(chat): MessageBlock dispatch with markdown AgentMessage" +``` + +--- + +## Task 15: `ToolCallBlock.svelte` (inline collapsed, expand on click, tool_result join) + +**Definition:** Renders a one-liner `🔧 {tool}: {summary}` with a disclosure triangle. Click to expand input (JSON pretty) and attached `tool_result` (if any). +**Constraints:** `tool_result` is matched by `call_id`. Matching done in `Transcript.svelte` — pass `{ call: ToolCallEvent, result: ToolResultEvent | null }` into the component. +**Validation:** Component test: click expands; tool_result attaches when present; `is_error` adds an error class. + +**Files:** +- Create: `src/lib/chat/ToolCallBlock.svelte` +- Create: `src/lib/chat/ToolCallBlock.test.ts` +- Modify: `src/lib/chat/Transcript.svelte` (do call-id join) + +**Key test:** +```ts +it("expands to show tool_result", async () => { + const call = { kind: "tool_call", payload: { call_id: "c1", tool: "Bash", input: { command: "ls" } } } as any; + const result = { kind: "tool_result", payload: { call_id: "c1", output: "file\n", is_error: false } } as any; + const { getByRole, getByText } = render(ToolCallBlock, { call, result }); + await fireEvent.click(getByRole("button")); + expect(getByText(/file/)).toBeTruthy(); +}); +``` + +**Commit:** +```bash +git commit -m "feat(chat): collapsed ToolCallBlock with tool_result join" +``` + +--- + +## Task 16: `ToolApprovalBlock.svelte` (inline Approve / Deny) + +**Definition:** When `statusState === "waiting_for_tool_approval"` and the last `tool_call` has no matching `tool_result`, render Approve / Deny / Deny-with-reason buttons on that block. On click, POST `tool_approval` inbox. +**Constraints:** Buttons disabled while request in flight. Deny-with-reason expands an inline textarea. +**Validation:** Component test: clicking Approve calls `client.sendMessage` with `{kind:'tool_approval', call_id, approved:true}`. + +**Files:** +- Create: `src/lib/chat/ToolApprovalBlock.svelte` +- Create: `src/lib/chat/ToolApprovalBlock.test.ts` +- Modify: `src/lib/chat/Transcript.svelte` (pass status + last pending call_id) + +**Commit:** +```bash +git commit -m "feat(chat): inline tool approval Approve/Deny" +``` + +--- + +## Task 17: `ChatInput.svelte` (send + interrupt + disabled states) + +**Definition:** Textarea. `Cmd+Enter` sends `user_text`. `Esc` sends `interrupt` when `statusState !== "idle"`. Disabled on `ended`/`failed`. +**Constraints:** Optimistic insert with seq from 202 response. Dedupe relies on the reducer (Task 7). +**Validation:** Component test: `Cmd+Enter` triggers `client.sendMessage`; `Esc` triggers interrupt only when running. + +**Files:** +- Create: `src/lib/chat/ChatInput.svelte` +- Create: `src/lib/chat/ChatInput.test.ts` +- Modify: `src/lib/chat/ChatView.svelte` (mount `` below transcript) + +**Commit:** +```bash +git commit -m "feat(chat): ChatInput with Cmd+Enter send and Esc interrupt" +``` + +--- + +## Task 18: HTTP-error policy in `ChatView` + +**Definition:** Route the `DaemonHttpError` cases per the design doc table: 401 (re-read + retry once), 404 (close view, toast), 409 (disable input, banner), 422/503 (toast + keep open), network error (inline retry). +**Constraints:** Shared helper `src/lib/daemon/errors.ts` with a `classifyError(err)` function; `ChatView` / `ChatInput` call it. +**Validation:** Unit tests per case in `errors.test.ts`. + +**Files:** +- Create: `src/lib/daemon/errors.ts` +- Create: `src/lib/daemon/errors.test.ts` +- Modify: `src/lib/chat/ChatView.svelte`, `src/lib/chat/ChatInput.svelte` + +**Commit:** +```bash +git commit -m "feat(chat): standardize daemon HTTP-error handling" +``` + +--- + +## Task 19: Sidebar keyboard navigation for chat items + +**Definition:** Extend `HotkeyManager.getVisibleItems` (`src/lib/HotkeyManager.svelte:110-131`) so in `chat` mode the walked items are project → chats → next project, analogous to the `development` branch. `Enter` on chat → set `daemonStore.activeSessionId`. `Enter` on "+ New chat" → open `NewChatDialog`. +**Constraints:** Reuse existing `navigateItem` / `getVisibleItems` patterns. +**Validation:** Add a case to `HotkeyManager.test.ts` (pattern file already exists). + +**Files:** +- Modify: `src/lib/HotkeyManager.svelte` +- Modify: `src/lib/HotkeyManager.test.ts` + +**Commit:** +```bash +git commit -m "feat(chat): keyboard navigation for chat sidebar" +``` + +--- + +## Task 20: HotkeyHelp for chat mode + +**Definition:** Add chat mode hotkey reference in `src/lib/HotkeyHelp.svelte:36`. +**Constraints:** Pattern mirrors how `"agents"` is labeled. +**Validation:** Snapshot / text-content test that "Chat" mode label appears when mode = chat. + +**Files:** +- Modify: `src/lib/HotkeyHelp.svelte` + +**Commit:** +```bash +git commit -m "feat(chat): HotkeyHelp label for chat mode" +``` + +--- + +## Task 21: Integration test — fake agent end-to-end (spine) + +**Definition:** Playwright test that exercises the full stack: start the daemon with the fake agent, open the Tauri app, switch to chat mode, create a chat, send a message, observe streaming deltas, finalized text, a tool_call expand, a tool approval flow, then kill the daemon and verify empty state. +**Constraints:** Reuse existing e2e harness. Reference skill: `@the-controller-general-e2e-eval`. The daemon binary must be built once per run; fake agent binary is built by the daemon's own `cargo test` (shared by `cargo build` with `[[bin]]`). +**Validation:** The test passes end-to-end. + +**Files:** +- Create: `tests/e2e/chat-mode.spec.ts` (match whatever directory the existing Playwright tests use; discover via `git ls-files | grep '\.spec\.ts$'`) +- Create: `scripts/test-chat-integration.sh` — builds daemon, starts it with `TCD_STATE_DIR=$(mktemp -d)` + `TCD_AGENT_CLAUDE_BINARY=`, runs Playwright, tears down. + +**Sketch:** +```ts +test("chat mode end-to-end against fake agent", async ({ page }) => { + // daemon already running via scripts/test-chat-integration.sh + await page.goto(appUrl); + await page.keyboard.press(" "); + await page.keyboard.press("c"); + await expect(page.getByText("Chat")).toBeVisible(); + // select first project's "+ New chat" + await page.getByText("+ New chat").first().click(); + await page.getByLabel("Agent").selectOption("claude"); + await page.getByRole("button", { name: "Create" }).click(); + // type a message + await page.keyboard.type("hello"); + await page.keyboard.press("Meta+Enter"); + await expect(page.getByText(/scripted-reply/)).toBeVisible({ timeout: 5000 }); + // ... +}); +``` + +**Commit:** +```bash +git commit -m "test(chat): Playwright integration against fake agent" +``` + +--- + +## Final task: Manual smoke and PR + +1. Start daemon: `~/projects/the-controller-daemon/target/release/the-controller-daemon` with `TCD_AGENT_CLAUDE_BINARY` pointed at the real `claude` binary. +2. `pnpm tauri dev` in the worktree. `Space c` → chat mode. +3. Create a chat in any project. Send "hello". Verify streamed reply + tool calls + interrupt work. +4. Stop the daemon; verify `DaemonEmptyState` appears. +5. Restart daemon; click Retry; verify state restores. +6. Run full test suite: `pnpm test -- --run` + `cd src-tauri && cargo test`. +7. Use `@the-controller-verification-before-completion` before declaring done. +8. Open PR per `@the-controller-finishing-a-development-branch`. + +--- + +## Notes for implementer + +- **Svelte 5 runes:** module-level `$state` requires `.svelte.ts` or `.svelte` files — see Task 8. +- **Token over WS:** using `?token=` query param for v1; revisit if we need Bearer in Upgrade. +- **Markdown escaping:** use the existing `src/lib/markdown.ts` helpers — they already handle the sanitization. Do not introduce a new markdown library. +- **Dev UX:** while iterating, run `pnpm test` in one terminal (watch mode) and `pnpm tauri dev` in another. +- **Daemon unreachable during tests:** every test that would hit `fetch('http://127.0.0.1:4867/...')` MUST stub `fetch` or `DaemonClient`. Never let unit tests hit the network. diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts new file mode 100644 index 00000000..0b5ddd78 --- /dev/null +++ b/e2e/specs/chat-mode.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from "@playwright/test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; + +// User story: +// Actor: A user wanting to use the new "chat" workspace mode backed by the +// the-controller-daemon. +// Action: Enters chat mode (Space -> c) with no daemon running; then (when +// the daemon is built) starts the daemon against the fake_agent and +// retries the connection. +// Outcome: Empty state is shown when daemon is unreachable. After the daemon +// is started, retrying transitions out of the empty state. +// +// NOTE: The daemon-reachable leg of this test is skipped by default because +// it requires both (a) the daemon & fake_agent binaries to be built at the +// canonical paths below, and (b) the Axum test server (src-tauri/bin/server) +// to expose `/api/read_daemon_token`. As of Task 21 the Axum server does not +// expose that route, so the browser harness cannot load the daemon token even +// if the daemon is running. Full end-to-end validation requires running under +// Tauri (where `read_daemon_token` is a real Tauri command). See Task 21 +// follow-ups in docs/plans/chat-mode.md. + +const DAEMON_REPO = "/Users/noelkwan/projects/the-controller-daemon"; +const DAEMON_BIN = `${DAEMON_REPO}/target/release/the-controller-daemon`; +const FAKE_AGENT_BIN = `${DAEMON_REPO}/target/debug/fake_agent`; + +async function switchToChatMode(page: import("@playwright/test").Page) { + await page.keyboard.press("Space"); + // Wait for the workspace-mode picker to mount before issuing the next key. + // Using the picker's class-based locator matches the pattern in + // architecture-logs.spec.ts and avoids races on slow CI. + await expect(page.locator(".picker")).toBeVisible({ timeout: 3_000 }); + await page.keyboard.press("c"); +} + +test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); + + await switchToChatMode(page); + + // Core assertion: the DaemonEmptyState component renders its heading when + // the daemon cannot be reached. In browser-mode e2e, `read_daemon_token` + // is not exposed by the Axum server, so bootstrap fails deterministically. + await expect(page.getByRole("heading", { name: "Daemon not running" })).toBeVisible({ + timeout: 5_000, + }); + + // The Retry button should be present and clickable without error. + const retry = page.getByRole("button", { name: "Retry" }); + await expect(retry).toBeVisible(); + await retry.click(); + // Still unreachable after retry (same browser-mode constraint), so the + // empty state must remain visible. This is a regression guard: if a + // refactor silently hides the empty state, this fails. + await expect(page.getByRole("heading", { name: "Daemon not running" })).toBeVisible({ + timeout: 2_000, + }); +}); + +// --------------------------------------------------------------------------- +// Daemon-reachable leg — SKIPPED unless the daemon binaries are built. +// Even when they are, this cannot pass under the browser harness; it is +// retained as a hook for a future Tauri-native e2e runner. +// --------------------------------------------------------------------------- +const daemonBinariesPresent = existsSync(DAEMON_BIN) && existsSync(FAKE_AGENT_BIN); + +test.describe("chat mode with daemon reachable", () => { + test.skip( + !daemonBinariesPresent, + `daemon binaries not built at ${DAEMON_BIN} / ${FAKE_AGENT_BIN}. ` + + "Run scripts/chat-integration-daemon.sh build to produce them.", + ); + + let daemon: ChildProcess | null = null; + let stateDir: string | null = null; + + test.beforeAll(async () => { + stateDir = mkdtempSync(join(tmpdir(), "tcd-e2e-")); + daemon = spawn(DAEMON_BIN, [], { + env: { + ...process.env, + TCD_STATE_DIR: stateDir, + TCD_AGENT_CLAUDE_BINARY: FAKE_AGENT_BIN, + }, + stdio: "pipe", + }); + // Give the daemon a moment to write its token file and start listening. + await new Promise((r) => setTimeout(r, 1500)); + }); + + test.afterAll(async () => { + if (daemon && daemon.pid) { + daemon.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 300)); + if (!daemon.killed) daemon.kill("SIGKILL"); + } + if (stateDir) { + try { + rmSync(stateDir, { recursive: true, force: true }); + } catch { + // best effort + } + } + }); + + test("workspace renders out of the empty state after Retry", async ({ page }) => { + // Known limitation: the Axum test server does not forward + // `read_daemon_token`, so the bootstrap still fails here. This test is + // intentionally marked as an expected failure until either (a) the + // Axum server exposes the route or (b) we run the e2e against a real + // Tauri build. Keeping the scaffolding makes it trivial to flip on once + // that unblock lands. + test.fixme(true, "Requires /api/read_daemon_token on the Axum test server"); + + await page.goto("/"); + await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); + await switchToChatMode(page); + + await page.getByRole("button", { name: "Retry" }).click(); + await expect( + page.getByRole("heading", { name: "Daemon not running" }), + ).toHaveCount(0, { timeout: 5_000 }); + }); +}); diff --git a/package.json b/package.json index 3e3c495a..4980b993 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@testing-library/user-event": "^14.6.1", "@types/node": "^25.4.0", "glob": "^13.0.6", - "jsdom": "^28.1.0", + "jsdom": "^26.1.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 854e7517..c0176272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,7 +71,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.3.1 - version: 5.3.1(svelte@5.53.6)(vite@6.4.1(@types/node@25.4.0))(vitest@4.0.18(@types/node@25.4.0)(jsdom@28.1.0)) + version: 5.3.1(svelte@5.53.6)(vite@6.4.1(@types/node@25.4.0))(vitest@4.0.18(@types/node@25.4.0)(jsdom@26.1.0)) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -82,8 +82,8 @@ importers: specifier: ^13.0.6 version: 13.0.6 jsdom: - specifier: ^28.1.0 - version: 28.1.0 + specifier: ^26.1.0 + version: 26.1.0 svelte: specifier: ^5.0.0 version: 5.53.6 @@ -98,28 +98,18 @@ importers: version: 6.4.1(@types/node@25.4.0) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.4.0)(jsdom@28.1.0) + version: 4.0.18(@types/node@25.4.0)(jsdom@26.1.0) packages: - '@acemir/cssom@0.9.31': - resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} - '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@asamuzakjp/css-color@5.0.1': - resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/dom-selector@6.8.1': - resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -136,10 +126,6 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} - '@bramus/specificity@2.4.2': - resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} - hasBin: true - '@chevrotain/cst-dts-gen@11.1.2': resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} @@ -188,36 +174,33 @@ packages: '@codemirror/view@6.39.17': resolution: {integrity: sha512-Aim4lFqhbijnchl83RLfABWueSGs1oUCSv0mru91QdhpXQeNKprIdRO9LWA4cYkJvuYTKGJN7++9MXx8XW43ag==} - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} - engines: {node: '>=20.19.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} - '@csstools/css-calc@3.1.1': - resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} - engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-color-parser@4.0.2': - resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} - engines: {node: '>=20.19.0'} + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-parser-algorithms@4.0.0': - resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} - engines: {node: '>=20.19.0'} + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-syntax-patches-for-csstree@1.0.28': - resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==} + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-tokenizer@4.0.0': - resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} - engines: {node: '>=20.19.0'} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -375,15 +358,6 @@ packages: cpu: [x64] os: [win32] - '@exodus/bytes@1.14.1': - resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@noble/hashes': ^1.8.0 || ^2.0.0 - peerDependenciesMeta: - '@noble/hashes': - optional: true - '@fontsource/geist-mono@5.2.7': resolution: {integrity: sha512-xVPVFISJg/K0VVd+aQN0Y7X/sw9hUcJPyDWFJ5GpyU3bHELhoRsJkPSRSHXW32mOi0xZCUQDOaPj1sqIFJ1FGg==} @@ -487,79 +461,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -640,35 +601,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.0': resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.0': resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.0': resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.0': resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} @@ -923,9 +879,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@5.0.4: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} @@ -970,16 +923,12 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssstyle@6.1.0: - resolution: {integrity: sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==} - engines: {node: '>=20'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} @@ -1137,9 +1086,9 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} - data-urls@7.0.0: - resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -1231,9 +1180,9 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -1267,9 +1216,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - jsdom@28.1.0: - resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -1303,6 +1252,9 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -1319,9 +1271,6 @@ packages: engines: {node: '>= 20'} hasBin: true - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - mermaid@11.13.0: resolution: {integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==} @@ -1352,14 +1301,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -1420,10 +1372,6 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -1435,6 +1383,9 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -1502,20 +1453,20 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.23: - resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - tldts@7.0.23: - resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} @@ -1532,10 +1483,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} - engines: {node: '>=20.18.1'} - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -1649,23 +1596,40 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} - engines: {node: '>=20'} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} - whatwg-mimetype@5.0.0: - resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} - engines: {node: '>=20'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-url@16.0.1: - resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1678,8 +1642,6 @@ packages: snapshots: - '@acemir/cssom@0.9.31': {} - '@adobe/css-tools@4.4.4': {} '@antfu/install-pkg@1.1.0': @@ -1687,23 +1649,13 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@asamuzakjp/css-color@5.0.1': - dependencies: - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.2.6 - - '@asamuzakjp/dom-selector@6.8.1': + '@asamuzakjp/css-color@3.2.0': dependencies: - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 - css-tree: 3.1.0 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.6 - - '@asamuzakjp/nwsapi@2.3.9': {} + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 '@babel/code-frame@7.29.0': dependencies: @@ -1717,10 +1669,6 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} - '@bramus/specificity@2.4.2': - dependencies: - css-tree: 3.1.0 - '@chevrotain/cst-dts-gen@11.1.2': dependencies: '@chevrotain/gast': 11.1.2 @@ -1824,27 +1772,25 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@csstools/color-helpers@6.0.2': {} + '@csstools/color-helpers@5.1.0': {} - '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-syntax-patches-for-csstree@1.0.28': {} + '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-tokenizer@4.0.0': {} + '@csstools/css-tokenizer@3.0.4': {} '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1924,8 +1870,6 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@exodus/bytes@1.14.1': {} - '@fontsource/geist-mono@5.2.7': {} '@fontsource/geist-sans@5.2.5': {} @@ -2192,14 +2136,14 @@ snapshots: dependencies: svelte: 5.53.6 - '@testing-library/svelte@5.3.1(svelte@5.53.6)(vite@6.4.1(@types/node@25.4.0))(vitest@4.0.18(@types/node@25.4.0)(jsdom@28.1.0))': + '@testing-library/svelte@5.3.1(svelte@5.53.6)(vite@6.4.1(@types/node@25.4.0))(vitest@4.0.18(@types/node@25.4.0)(jsdom@26.1.0))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.53.6) svelte: 5.53.6 optionalDependencies: vite: 6.4.1(@types/node@25.4.0) - vitest: 4.0.18(@types/node@25.4.0)(jsdom@28.1.0) + vitest: 4.0.18(@types/node@25.4.0)(jsdom@26.1.0) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -2411,10 +2355,6 @@ snapshots: balanced-match@4.0.4: {} - bidi-js@1.0.3: - dependencies: - require-from-string: 2.0.2 - brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -2457,19 +2397,12 @@ snapshots: crelt@1.0.6: {} - css-tree@3.1.0: - dependencies: - mdn-data: 2.12.2 - source-map-js: 1.2.1 - css.escape@1.5.1: {} - cssstyle@6.1.0: + cssstyle@4.6.0: dependencies: - '@asamuzakjp/css-color': 5.0.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.28 - css-tree: 3.1.0 - lru-cache: 11.2.6 + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): dependencies: @@ -2655,12 +2588,10 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 - data-urls@7.0.0: + data-urls@5.0.0: dependencies: - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 - transitivePeerDependencies: - - '@noble/hashes' + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 dayjs@1.11.19: {} @@ -2751,11 +2682,9 @@ snapshots: hachure-fill@0.5.2: {} - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@4.0.0: dependencies: - '@exodus/bytes': 1.14.1 - transitivePeerDependencies: - - '@noble/hashes' + whatwg-encoding: 3.1.1 http-proxy-agent@7.0.2: dependencies: @@ -2789,32 +2718,32 @@ snapshots: js-tokens@4.0.0: {} - jsdom@28.1.0: + jsdom@26.1.0: dependencies: - '@acemir/cssom': 0.9.31 - '@asamuzakjp/dom-selector': 6.8.1 - '@bramus/specificity': 2.4.2 - '@exodus/bytes': 1.14.1 - cssstyle: 6.1.0 - data-urls: 7.0.0 + cssstyle: 4.6.0 + data-urls: 5.0.0 decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.0 - undici: 7.22.0 + tough-cookie: 5.1.2 w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.1 - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - - '@noble/hashes' + - bufferutil - supports-color + - utf-8-validate katex@0.16.38: dependencies: @@ -2840,6 +2769,8 @@ snapshots: lodash-es@4.17.23: {} + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} lz-string@1.5.0: {} @@ -2850,8 +2781,6 @@ snapshots: marked@16.4.2: {} - mdn-data@2.12.2: {} - mermaid@11.13.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -2897,11 +2826,13 @@ snapshots: nanoid@3.3.11: {} + nwsapi@2.2.23: {} + obug@2.1.1: {} package-manager-detector@1.6.0: {} - parse5@8.0.0: + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -2962,8 +2893,6 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - require-from-string@2.0.2: {} - robust-predicates@3.0.2: {} rollup@4.59.0: @@ -3004,6 +2933,8 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + rrweb-cssom@0.8.0: {} + rw@1.3.3: {} sade@1.8.1: @@ -3076,17 +3007,17 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@7.0.23: {} + tldts-core@6.1.86: {} - tldts@7.0.23: + tldts@6.1.86: dependencies: - tldts-core: 7.0.23 + tldts-core: 6.1.86 - tough-cookie@6.0.0: + tough-cookie@5.1.2: dependencies: - tldts: 7.0.23 + tldts: 6.1.86 - tr46@6.0.0: + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -3098,8 +3029,6 @@ snapshots: undici-types@7.18.2: {} - undici@7.22.0: {} - uuid@11.1.0: {} vite@6.4.1(@types/node@25.4.0): @@ -3118,7 +3047,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.4.0) - vitest@4.0.18(@types/node@25.4.0)(jsdom@28.1.0): + vitest@4.0.18(@types/node@25.4.0)(jsdom@26.1.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.4.0)) @@ -3142,7 +3071,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.4.0 - jsdom: 28.1.0 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -3179,23 +3108,26 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - webidl-conversions@8.0.1: {} + webidl-conversions@7.0.0: {} - whatwg-mimetype@5.0.0: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} - whatwg-url@16.0.1: + whatwg-url@14.2.0: dependencies: - '@exodus/bytes': 1.14.1 - tr46: 6.0.0 - webidl-conversions: 8.0.1 - transitivePeerDependencies: - - '@noble/hashes' + tr46: 5.1.1 + webidl-conversions: 7.0.0 why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.20.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/scripts/chat-integration-daemon.sh b/scripts/chat-integration-daemon.sh new file mode 100755 index 00000000..81a63031 --- /dev/null +++ b/scripts/chat-integration-daemon.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# chat-integration-daemon.sh — helper to build and/or run the +# the-controller-daemon + fake_agent pair for Task 21 chat-mode e2e tests. +# +# Usage: +# scripts/chat-integration-daemon.sh build # cargo build both binaries if missing +# scripts/chat-integration-daemon.sh run # spawn the daemon in foreground +# scripts/chat-integration-daemon.sh status # check whether binaries exist +# +# Environment: +# TCD_DAEMON_REPO Path to the the-controller-daemon checkout. +# Default: /Users/noelkwan/projects/the-controller-daemon +# +# When running, TCD_STATE_DIR is set to a fresh mktemp dir so the token +# lives out of the user's real $HOME/.the-controller/ and TCD_AGENT_CLAUDE_BINARY +# points at the built fake_agent. The script prints the state dir so the +# caller can locate daemon.token for the e2e harness. + +set -euo pipefail + +DAEMON_REPO="${TCD_DAEMON_REPO:-/Users/noelkwan/projects/the-controller-daemon}" +DAEMON_BIN="$DAEMON_REPO/target/release/the-controller-daemon" +FAKE_AGENT_BIN="$DAEMON_REPO/target/debug/fake_agent" + +ensure_repo() { + if [[ ! -d "$DAEMON_REPO" ]]; then + echo "ERROR: daemon repo not found at $DAEMON_REPO" >&2 + echo "Set TCD_DAEMON_REPO or check out the-controller-daemon there." >&2 + exit 1 + fi +} + +cmd_build() { + ensure_repo + if [[ ! -x "$DAEMON_BIN" ]]; then + echo "Building the-controller-daemon (release)..." + (cd "$DAEMON_REPO" && cargo build --release --bin the-controller-daemon) + else + echo "the-controller-daemon already built: $DAEMON_BIN" + fi + if [[ ! -x "$FAKE_AGENT_BIN" ]]; then + echo "Building fake_agent (debug)..." + (cd "$DAEMON_REPO" && cargo build --bin fake_agent) + else + echo "fake_agent already built: $FAKE_AGENT_BIN" + fi +} + +cmd_status() { + if [[ -x "$DAEMON_BIN" ]]; then + echo "daemon: $DAEMON_BIN (present)" + else + echo "daemon: $DAEMON_BIN (MISSING)" + fi + if [[ -x "$FAKE_AGENT_BIN" ]]; then + echo "fake_agent: $FAKE_AGENT_BIN (present)" + else + echo "fake_agent: $FAKE_AGENT_BIN (MISSING)" + fi +} + +cmd_run() { + ensure_repo + if [[ ! -x "$DAEMON_BIN" || ! -x "$FAKE_AGENT_BIN" ]]; then + cmd_build + fi + local state_dir + state_dir="$(mktemp -d -t tcd-e2e-XXXXXX)" + echo "TCD_STATE_DIR=$state_dir" + echo "TCD_AGENT_CLAUDE_BINARY=$FAKE_AGENT_BIN" + echo "Starting daemon. Ctrl-C to stop." + TCD_STATE_DIR="$state_dir" \ + TCD_AGENT_CLAUDE_BINARY="$FAKE_AGENT_BIN" \ + exec "$DAEMON_BIN" +} + +case "${1:-status}" in + build) cmd_build ;; + run) cmd_run ;; + status) cmd_status ;; + *) + echo "Usage: $0 {build|run|status}" >&2 + exit 2 + ;; +esac diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 123ad446..c4758cbe 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,6 +10,7 @@ use crate::storage::ProjectInventory; use crate::token_usage::{self, TokenDataPoint}; use crate::worktree::WorktreeManager; +mod daemon; mod github; mod kanban; mod media; @@ -2018,6 +2019,11 @@ pub fn log_frontend_error(message: String) { eprintln!("[FRONTEND] {}", message); } +#[tauri::command] +pub async fn read_daemon_token() -> Result { + daemon::read_daemon_token().await +} + fn find_main_branch_oid(repo: &git2::Repository) -> Option { for name in &["refs/heads/master", "refs/heads/main"] { if let Ok(reference) = repo.find_reference(name) { diff --git a/src-tauri/src/commands/daemon.rs b/src-tauri/src/commands/daemon.rs new file mode 100644 index 00000000..9790bda1 --- /dev/null +++ b/src-tauri/src/commands/daemon.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +pub(crate) fn daemon_token_path() -> PathBuf { + if let Ok(dir) = std::env::var("TCD_STATE_DIR") { + return PathBuf::from(dir).join("daemon.token"); + } + let home = std::env::var("HOME").unwrap_or_default(); + PathBuf::from(home) + .join(".the-controller") + .join("daemon.token") +} + +pub(crate) fn read_token_from(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path) + .map_err(|e| format!("read daemon token at {}: {}", path.display(), e))?; + let s = String::from_utf8(bytes).map_err(|e| format!("token not utf-8: {}", e))?; + Ok(s.trim().to_string()) +} + +pub(crate) async fn read_daemon_token() -> Result { + let path = daemon_token_path(); + tokio::task::spawn_blocking(move || read_token_from(&path)) + .await + .map_err(|e| format!("join error: {}", e))? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn reads_and_trims_token() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("daemon.token"); + let mut f = std::fs::File::create(&p).unwrap(); + writeln!(f, "abc123").unwrap(); + assert_eq!(read_token_from(&p).unwrap(), "abc123"); + } + + #[test] + fn missing_token_returns_err() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("daemon.token"); + let err = read_token_from(&p).unwrap_err(); + assert!(err.contains("daemon.token"), "got: {}", err); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe101372..e3d1f948 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,6 +117,7 @@ pub fn run() { commands::get_repo_head, commands::get_session_token_usage, commands::log_frontend_error, + commands::read_daemon_token, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/token_usage.rs b/src-tauri/src/token_usage.rs index 97b8c801..c9d2fe05 100644 --- a/src-tauri/src/token_usage.rs +++ b/src-tauri/src/token_usage.rs @@ -184,7 +184,7 @@ fn find_codex_session_file(sessions_dir: &Path, working_dir: &str) -> Result {:else if workspaceModeState.current === "kanban"} + {:else if workspaceModeState.current === "chat"} + {:else} {/if} diff --git a/src/lib/HotkeyHelp.svelte b/src/lib/HotkeyHelp.svelte index b5ec3df1..eca9a439 100644 --- a/src/lib/HotkeyHelp.svelte +++ b/src/lib/HotkeyHelp.svelte @@ -12,6 +12,10 @@ const workspaceModeState = fromStore(workspaceMode); const sections = $derived(getHelpSections(workspaceModeState.current)); + const modeLabel = $derived( + workspaceModeState.current === "chat" ? "Chat" : + workspaceModeState.current === "agents" ? "Agents" : "Development" + ); function handleKeydown(e: KeyboardEvent) { if (e.key === "Escape") { @@ -33,7 +37,7 @@