diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 17:09:57 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 17:09:57 +0900 |
| commit | 6b0bb19e914a3bbaeca198ac1627d698c3a13a76 (patch) | |
| tree | 5f9a9c0856e0eefa908963d6e0d650b2853808a5 | |
| parent | 38db3827870960f466be89afbc49f91238d46144 (diff) | |
| download | dispatch-web-6b0bb19e914a3bbaeca198ac1627d698c3a13a76.tar.gz dispatch-web-6b0bb19e914a3bbaeca198ac1627d698c3a13a76.zip | |
feat(computer): SSH computer selection + status + workspace default (handoff #2)
Mirrors the cwd/workspace UI for the SSH-computer feature:
- New feature library src/features/computer/:
- logic/view-model.ts (pure): viewComputer/viewComputerStatus/viewTestResult/
summarizeComputers/formatHost/knownHostLabel + state->badge for the 4
ComputerStatusResponse states + SaveComputer/LoadComputerStatus/
TestComputer/LoadComputers ports. 20 view-model tests.
- ui/ComputerField.svelte: per-conversation selector (dropdown +
connection-status badge + Test-connection, polling the selected alias).
- ui/ComputerSelect.svelte: reusable Local/computers dropdown, shared with
the workspace default-computer control.
- AppStore: computerId state + refreshComputer (at every focus site, parallel
to refreshCwd) + setComputer (PUT /conversations/:id/computer, null=clear) +
global computers catalog (GET /computers on boot, like models) +
computerStatus(alias) + testComputer(alias). chat.send UNCHANGED (resolved
server-side like cwd).
- App.svelte: ComputerField in the Model sidebar view next to CwdField; adapted
ports wrap the store.
- workspaces: setDefaultComputer on WorkspaceHttp+WorkspaceStore
(PUT /workspaces/:id/default-computer); default-computer selector in
WorkspaceCard (reuses ComputerSelect); router passes store.computers through.
- Re-mirrored .dispatch/transport-contract.reference.md (Computers section +
ChatRequest.computerId); updated .dispatch/wire.reference.md (Computer/
ComputerEntry/defaultComputerId + the provider-retry divergence note from
handoff #1); GLOSSARY + backend-handoff.md (handoff #2, §2e).
Transparency invariant: the computer is USER-facing only (a tool-execution
target, never part of the model prompt); the agent never sees it.
Verify: 795/795 tests green; biome clean; vite build succeeds; 0 typecheck
errors from the computer feature. (11 pre-existing svelte-check errors remain
from the open §2d provider-retry divergence — backend feature/ssh-support
still lacks TurnProviderRetryEvent; not from this feature.)
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 100 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 70 | ||||
| -rw-r--r-- | GLOSSARY.md | 2 | ||||
| -rw-r--r-- | backend-handoff.md | 123 | ||||
| -rw-r--r-- | src/App.svelte | 2 | ||||
| -rw-r--r-- | src/app/App.svelte | 42 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 185 | ||||
| -rw-r--r-- | src/features/computer/index.ts | 31 | ||||
| -rw-r--r-- | src/features/computer/logic/view-model.test.ts | 167 | ||||
| -rw-r--r-- | src/features/computer/logic/view-model.ts | 184 | ||||
| -rw-r--r-- | src/features/computer/ui/ComputerField.svelte | 195 | ||||
| -rw-r--r-- | src/features/computer/ui/ComputerSelect.svelte | 39 | ||||
| -rw-r--r-- | src/features/workspaces/adapter/http.ts | 23 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 1 | ||||
| -rw-r--r-- | src/features/workspaces/store.svelte.ts | 8 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 40 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 23 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 10 |
18 files changed, 1224 insertions, 21 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 8c6023a..48ba45d 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -5,9 +5,20 @@ > permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for > READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status). Regenerate whenever +> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers). Regenerate whenever > it changes. > +> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the +> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`), +> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`), +> `SetConversationComputerRequest` + `ConversationComputerResponse` +> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` +> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/ +> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so +> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See +> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave — +> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.) +> > **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds > `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint > `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns @@ -117,6 +128,16 @@ export interface ChatRequest { * defaultCwd = null). */ readonly workspaceId?: string; + + /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; } /** @@ -843,5 +864,82 @@ export interface DeleteWorkspaceResponse { /** Conversations that were closed (status → "closed") by this delete. */ readonly closedCount: number; } + +// ─── Computers (SSH handoff #2) ───────────────────────────────────────────── + +/** + * Response of `GET /computers` — every remote computer discovered from the + * system's `~/.ssh/config`, sorted by `alias`. Parallel to + * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a + * usage count). There is no Computer CRUD — to add one, the user adds a `Host` + * block to `~/.ssh/config` and Dispatch discovers it on the next read. + */ +export interface ComputerListResponse { + readonly computers: readonly ComputerEntry[]; +} + +/** + * Response of `GET /computers/:alias` — a single computer. Parallel to + * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users + * select; the remaining fields are resolved from the SSH config. + */ +export interface ComputerResponse extends Computer {} + +/** + * Response of `GET /computers/:alias/status` — the live connection state of a + * computer (whether Dispatch currently holds an open SSH session to it). Drives + * the frontend connection indicator. `error` is present only when + * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. + */ +export interface ComputerStatusResponse { + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; +} + +/** + * Body of `PUT /conversations/:id/computer` — set or clear the conversation's + * persisted computer selection (the computer analog of `SetCwdRequest`). Pass + * `null` to clear → the conversation inherits the workspace's + * `defaultComputerId`, then `null`/local. An unknown alias is not validated here + * (the connection resolves at turn time; an unreachable host → turn error, not + * a 400). Mirrors the cwd/model PUT clear semantics. + */ +export interface SetConversationComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `GET /conversations/:id/computer`. `computerId` is the persisted + * SSH `Host` alias, or `null` when never set (the conversation then inherits + * the workspace default → local). Parallel to `CwdResponse`. + */ +export interface ConversationComputerResponse { + readonly conversationId: string; + readonly computerId: string | null; +} + +/** + * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's + * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`). + * `null` means local (no SSH). Conversations in the workspace with no + * `computerId` of their own inherit this. + */ +export interface SetWorkspaceDefaultComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `POST /computers/:alias/test` — the result of a one-shot + * connectivity probe (Dispatch opens an SSH connection to the alias, runs a + * trivial command, then closes). `ok` is true on success; `error` carries the + * failure reason (e.g. auth refused, host unreachable) when `ok` is false. + */ +export interface TestComputerResponse { + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} ``` diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index b7003ec..05ed40b 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,13 +4,30 @@ > types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission > prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces). Regenerate whenever `@dispatch/wire` changes. +> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers). Regenerate whenever `@dispatch/wire` changes. > > **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds > `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required > `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A > workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that > haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`. +> +> **2026-06-25 delta (SSH handoff #1 — ADDITIVE to `[email protected]`, NO version bump):** adds a REQUIRED +> `defaultComputerId: string | null` on `Workspace` (null = local / no SSH; the computer analog of +> `defaultCwd`) and two new read-only view types: `Computer` (a discovered `~/.ssh/config` `Host` alias) +> and `ComputerEntry extends Computer` (a list entry with a `usageCount`). `alias` IS the `computerId` +> users select (persisted per-conversation/per-workspace like cwd). The full HTTP API surface +> (`GET /computers`, `PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`, +> `GET /computers/:alias/status`, `chat.send computerId`) comes in a LATER handoff — NOT consumed yet. +> +> **⚠️ CROSS-REPO DIVERGENCE (2026-06-25, BLOCKING FE typecheck):** the backend `feature/ssh-support` +> branch (where the SSH types landed) was cut from `8a74335` and is MISSING the `TurnProviderRetryEvent` / +> `provider-retry` `AgentEvent` addition that is on `dev` (and which the FE already consumes — see §2c of +> `backend-handoff.md`). The mirror below KEEPS `TurnProviderRetryEvent` (it is the FE's expected contract +> and matches `dev`); it is marked where it appears. Until the backend merges `dev` into +> `feature/ssh-support`, the FE pinned to the `feature/ssh-support` wire will NOT typecheck (11 errors, +> all the missing `provider-retry` seam). The SSH `Computer`/`defaultComputerId` types ARE present on +> `feature/ssh-support` and are consumed below. ```ts /** @@ -288,7 +305,7 @@ export type AgentEvent = | TurnUsageEvent | TurnStepCompleteEvent | TurnErrorEvent - | TurnProviderRetryEvent + | TurnProviderRetryEvent // ⚠️ divergent: present on `dev`, MISSING on the pinned `feature/ssh-support` wire (see header + backend-handoff.md §2c) | TurnDoneEvent | TurnSealedEvent | TurnSteeringEvent; @@ -611,6 +628,14 @@ export interface Workspace { readonly title: string; /** The workspace's default cwd, or `null` (fall through to server default). */ readonly defaultCwd: string | null; + /** + * The workspace's default computer — an SSH config `Host` alias that + * conversations in this workspace inherit when they set no `computerId` of + * their own. `null` means local (no SSH; today's behavior). The computer + * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` + * (per-conv `computerId` → this → `null`/local). + */ + readonly defaultComputerId: string | null; /** Epoch-ms when the workspace was first created. */ readonly createdAt: number; /** Epoch-ms of the most recent conversation activity in this workspace. */ @@ -625,4 +650,45 @@ export interface WorkspaceEntry extends Workspace { /** Number of conversations assigned to this workspace. */ readonly conversationCount: number; } + +// ─── Computers ─────────────────────────────────────────────────────────────── + +/** + * A read-only view of a remote computer discovered from the system's + * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity + * (there is no Computer CRUD store). To add a computer, the user adds a `Host` + * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()` + * read. Every field below is resolved from the config (first-match-wins for + * `HostName`/`User`/`Port`/`IdentityFile`). + * + * `alias` is the `computerId` users select — the string persisted per + * conversation and per workspace (the computer analog of `cwd`). `knownHost` + * drives the frontend "known/new" indicator and is read-only. + */ +export interface Computer { + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default the current user). */ + readonly user: string; + /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously + * connected). Drives the frontend "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; +} + +/** + * A computer entry in the list response (`GET /computers`) — a `Computer` plus + * a usage count. Parallel to `WorkspaceEntry`. + */ +export interface ComputerEntry extends Computer { + /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ + readonly usageCount: number; +} ``` diff --git a/GLOSSARY.md b/GLOSSARY.md index fe4360c..7914ef5 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -24,6 +24,8 @@ | **context window** | The model's MAXIMUM token capacity (the limit a **context size** is measured against). A FUTURE backend field — not on the wire yet. **Placeholder:** the composer status bar currently HARDCODES a `1,000,000`-token window for the `size / limit · pct%` readout + fill bar; swap to the real per-model value when the backend ships it (see `backend-handoff.md` §3). | max context, token limit (distinct from **context size**, the current usage) | | **message queue** | A per-conversation buffer of user messages awaiting mid-turn **steering** delivery (owned by the `message-queue` backend extension). Transient + in-memory; exposed to the FE as a per-conversation **surface** (`message-queue`, scope `conversation`; one `custom` field, `rendererId: "message-queue"`, `payload: QueuePayload`). NOT on the chat stream — it is control/state. Enqueued via `chat.queue` (WS) or `POST /conversations/:id/queue` (HTTP); auto-starts a turn if idle. `[email protected]`. | pending messages, steering queue | | **steering** | A user message injected into an in-flight turn at the tool-result boundary (drawn from the **message queue**): the model sees it alongside the tool results and may adjust course. Emitted on the chat stream as a `steering` `AgentEvent` (`TurnSteeringEvent`); the queue surface clears on drain (move, don't duplicate). If the turn ends with a non-empty queue (no tool call fired), the queue carries into a NEW turn as its opening prompt (no `steering` event). `[email protected]`. | mid-turn injection, course correction, interruption | +| **computer** | A remote SSH target discovered from the system's `~/.ssh/config` — a read-only VIEW, NOT an editable entity (no CRUD store; to add one the user edits `~/.ssh/config`). Backend-canonical (`[email protected]`, additive). On the wire as `Computer` (`{ alias, hostName, port, user, identityFile, knownHost }`) + `ComputerEntry extends Computer` (adds `usageCount`, for `GET /computers`). `alias` IS the **computerId** — the string persisted per-conversation / per-workspace (the computer analog of `cwd`). Resolution is SERVER-owned (never re-implement): per-conversation `computerId` → `workspace.defaultComputerId` → `null`/local. USER-facing only: a tool-execution target forwarded to tools, NEVER part of the model prompt (does not affect prompt caching); the agent never sees it. HTTP API (`GET /computers`, `GET`/`PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`, `GET /computers/:alias/status`, `POST /computers/:alias/test`) consumed in handoff #2. | ssh host, remote host, server, connection target | +| **computerId** | The string id of a **computer** — an SSH config `Host` alias users select. Persisted per-conversation and per-workspace (the computer analog of `cwd`/`workspaceId`). `null` means local (no SSH; today's behavior). On `Workspace` as the REQUIRED `defaultComputerId: string \| null` (null = local / no SSH; the computer analog of `defaultCwd`); per-conversation persistence via `GET`/`PUT`/`DELETE /conversations/:id/computer`. `chat.send` need not send it (resolved server-side from the persisted per-conversation value in the MVP). | ssh alias, host id, remote id | ## Frontend-specific | Term | Meaning | Aliases to avoid | diff --git a/backend-handoff.md b/backend-handoff.md index c82d9cb..cf09269 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,16 +5,19 @@ > **From:** dispatch-web orchestrator · **To:** `../dispatch-backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. -_Last updated: 2026-06-24 (`transport-contract` re-pinned 0.20.0 → 0.22.0; `file:` dep paths -fixed `../arch-rewrite` → `../dispatch-backend`; MCP server-status endpoint/types consumed + backend shipped -`GET /conversations/:id/mcp` (CR-12 resolved); transient `provider-retry` AgentEvent consumed (additive to -`[email protected]`, no version bump))._ +_Last updated: 2026-06-25 (SSH handoff #2 — full computer HTTP API consumed: `computer` feature folder + `ComputerField` +per-conversation selector + connection-status badge + Test-connection + workspace default-computer selector; store +`computerId`/`refreshComputer`/`setComputer` + `computers` catalog + `computerStatus`/`testComputer`. transport-contract +computer types re-mirrored. ⚠️ The handoff #1 `provider-retry` divergence (§2d) is STILL OPEN — backend `feature/ssh-support` +still lacks `TurnProviderRetryEvent` (not merged from `dev`); FE typecheck stays at 11 errors, all that seam. The computer +feature itself is typecheck-clean (0 errors) + 795 tests green. See §2e)._ **FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** -(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). +(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump), and **SSH-divergence** +(§2d — backend `feature/ssh-support` must merge `dev` so `provider-retry` is present alongside the new SSH types). Backend shipped CR-10 (workspace id on `conversation.open` / `conversation.statusChanged`), CR-11 (per-conversation model persistence), and CR-12 (`GET /conversations/:id/mcp`); FE has consumed all three. The backend also added the transient `provider-retry` `AgentEvent` (retry-with-backoff warning) to -`[email protected]` (additive — no version bump); FE consumed + re-mirrored `.dispatch/wire.reference.md` — see §2c. +`[email protected]` on `dev` (additive — no version bump); FE consumed + re-mirrored `.dispatch/wire.reference.md` — see §2c. FE re-pinned + re-mirrored `transport-contract`; `selectModel` persists to `PUT /conversations/:id/model` and conversation focus recalls the persisted model via `GET /conversations/:id/model`. FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp`) — see §2b. @@ -28,7 +31,7 @@ Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `transport-contrac | Package | Used for | |---|---| | `@dispatch/ui-contract` | surfaces + surface WS protocol | -| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus` | +| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`)/`Computer`/`ComputerEntry` (SSH handoff #1) | | `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): @@ -346,6 +349,112 @@ effect + re-render churn). A live ticking countdown (5,4,3,2,1…) could be adde --- +## 2d. SSH support — handoff #1 (wire types) → **PARTIALLY CONSUMED ✅ ⚠️ BLOCKED on a cross-repo divergence** + +The first of a few incremental SSH handoffs. The backend's `@dispatch/wire` (pinned `file:` dep) gained SSH-computer +types — **additive to `[email protected]`, NO version bump** (same pattern as `provider-retry`). The full HTTP API surface +(computer endpoints, `chat.send computerId`) comes in a LATER handoff; this one is wire-types only. + +**New wire types (consumed + re-mirrored):** +- `Workspace` gained a REQUIRED `defaultComputerId: string | null` (null = local / no SSH; the computer analog of + `defaultCwd`). Resolution is SERVER-owned (per-conv `computerId` → `workspace.defaultComputerId` → `null`/local). +- `Computer` — a read-only view of a discovered `~/.ssh/config` `Host` target: + `{ alias, hostName, port, user, identityFile, knownHost }`. `alias` IS the `computerId` users select. NOT an + editable entity (no CRUD store — the user edits `~/.ssh/config` to add one). +- `ComputerEntry extends Computer` — adds `usageCount` (for `GET /computers`). + +**FE (DONE):** +- Re-synced the `@dispatch/wire` `file:` dep (worktree layout note below) so the new types resolve. +- Re-mirrored `.dispatch/wire.reference.md`: added `Workspace.defaultComputerId`, the `Computer`/`ComputerEntry` + section, and a header delta note. +- Fixed the 2 `Workspace`/`WorkspaceEntry` test literals that broke (the handoff's expected break): + `src/features/workspaces/ui/WorkspaceCard.test.ts` (`fakeEntry`) and + `src/features/workspaces/logic/view-model.test.ts` (`ws` factory) — both now supply `defaultComputerId: null`. + (The conformance test's `provider-retry` case is the unrelated divergence below — NOT a `defaultComputerId` site.) +- Added `computer` / `computerId` to `GLOSSARY.md` (backend-canonical, adopted verbatim). + +**NOT started (correctly deferred):** the `computer` feature folder, the per-conversation + workspace-default +selectors, the connection-status badge, and `chat.send computerId` — all wait on the later HTTP-API handoff. + +### ⚠️ BLOCKING — `provider-retry` missing on the backend `feature/ssh-support` branch + +Picking up the SSH wire types surfaced a **cross-repo divergence**: the FE does NOT typecheck. The backend +`feature/ssh-support` branch (where the SSH types landed) was cut from `8a74335` and is MISSING the +`TurnProviderRetryEvent` / `provider-retry` `AgentEvent` addition that lives on `dev` (confirmed: present in the main +backend repo's `dev` `packages/wire/src/index.ts` lines 276/444; ABSENT from the worktree `feature/ssh-support` +branch source + `dist`). The FE branch already consumes `provider-retry` (commit `17ce479`, §2c) — so against the +pinned `feature/ssh-support` wire it now fails with **11 typecheck errors**, all the missing `provider-retry` seam: +`Module '"@dispatch/wire"' has no exported member 'TurnProviderRetryEvent'` (7 sites: +`core/chunks/types.ts`, `retry-banner.ts`, `selectors.ts`, `features/chat/store.svelte.ts`, +`features/chat/ui/ChatView.svelte`, `core/chunks/reducer.test.ts`, `retry-banner.test.ts`) + +`Type '"provider-retry"' is not comparable/assignable to the AgentEvent.type union` (4 sites: +`core/chunks/reducer.ts`, `core/wire/conformance.ts`, `core/wire/conformance.test.ts`). + +**This is backend-side.** The FE will NOT revert its shipped, tested `provider-retry` feature (that would regress +§2c), and per the constitution the FE cannot edit the backend repo. **Ask for the backend:** merge `dev` into +`feature/ssh-support` (or rebase the SSH waves 0–2 onto current `dev`) so `provider-retry` is present alongside the +new SSH types. Once merged + the wire `dist` rebuilt, the FE re-syncs the `file:` dep and the 11 errors clear with +ZERO further FE code changes (the `provider-retry` consumption is already complete + tested on the FE side). + +**FE status:** `defaultComputerId` break fully resolved (0 remaining); the ONLY thing blocking `bun run typecheck` +green is the backend `provider-retry` merge. `bun run test` / `build` are likewise blocked on the same compile seam. + +### Worktree environment note (not a contract change) +This worktree lays the repos out as `…/worktrees/ssh-support/{backend,frontend}`, but `package.json`'s canonical +`file:` paths point at `../dispatch-backend` (correct for the main `dispatch/{dispatch-backend,dispatch-web}` layout). +To keep `package.json` canonical (no worktree-specific hack committed), a symlink `../dispatch-backend → ../backend` +was created in the worktree parent (untracked, outside the repo), then `bun install` re-synced `node_modules/@dispatch/*`. +The backend wire `dist/` was already built + current (has the new types); no backend edit was made. + +--- + +## 2e. SSH support — handoff #2 (full computer HTTP API) → **CONSUMED ✅ (FE built; typecheck blocked only by §2d)** + +The backend shipped the full SSH computer HTTP/WS API (transport-contract types stable; the `ssh` extension that +provides the ComputerService is the last backend wave — until it lands, `GET /computers` returns `[]` and statuses +return `disconnected`, which the FE renders gracefully). The FE mirrors the existing `cwd`/`workspaces` UI. + +**New transport-contract types consumed (additive to `[email protected]`, NO version bump):** `ComputerListResponse` +(`GET /computers`), `ComputerResponse`, `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` +(`POST /computers/:alias/test`), `SetConversationComputerRequest` + `ConversationComputerResponse` +(`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` +(`PUT /workspaces/:id/default-computer`), and `computerId?: string` on `ChatRequest`/`ChatSendMessage`/`QueueRequest`. +`Computer`/`ComputerEntry` are `@dispatch/wire` (handoff #1). Re-mirrored `.dispatch/transport-contract.reference.md` +(added the Computers section + `ChatRequest.computerId`). + +**FE (DONE — mirrors cwd-lsp's consumer-defines-port pattern):** +- New feature library `src/features/computer/`: pure `logic/view-model.ts` (`viewComputer`/`viewComputerStatus`/ + `viewTestResult`/`summarizeComputers`/`formatHost`/`knownHostLabel` + state→badge mapping for the 4 + `ComputerStatusResponse.state`s + the `SaveComputer`/`LoadComputerStatus`/`TestComputer`/`LoadComputers` ports) — 20 + view-model tests green; `ui/ComputerField.svelte` (per-conversation selector: dropdown + connection-status badge + + Test-connection, polling the selected alias) + `ui/ComputerSelect.svelte` (a reusable Local/computers dropdown, shared + with the workspace default-computer control); `index.ts` (`ComputerField`/`ComputerSelect`/`manifest`/types). +- `AppStore` (`src/app/store.svelte.ts`): `computerId` reactive state + `refreshComputer()` (parallel to `refreshCwd`, + called at every focus site: boot, workspace switch, draft→tab, newDraft, selectTab, removeTabLocally) + + `setComputer(computerId: string | null)` (`PUT /conversations/:id/computer`, null = clear) + a global `computers` + catalog (`GET /computers` on boot, like `models`) + `computerStatus(alias)` + `testComputer(alias)`. New result types + `ComputerResult`/`ComputerStatusResult`/`TestComputerResult`. `chat.send` UNCHANGED (computer resolved server-side + from the persisted per-conversation value, exactly like cwd). +- `src/app/App.svelte`: `ComputerField` mounted in the "Model" sidebar view next to `CwdField`, keyed on + `currentConversationId`; adapted ports (`saveComputer`/`loadComputerStatus`/`testComputer`) wrap the store. +- Workspaces: `setDefaultComputer` added to `WorkspaceHttp` + `WorkspaceStore` (`PUT /workspaces/:id/default-computer`); + a default-computer selector (reusing `ComputerSelect`) added to `WorkspaceCard.svelte` next to the default-cwd control; + the router (`src/App.svelte`) passes `store.computers` through `WorkspacesHome` → `WorkspaceCard`. + +**Transparency invariant (held):** the computer is USER-facing only — it is a tool-execution target forwarded to tools +and NEVER part of the model prompt (so it does not affect prompt caching); the agent never sees it. Documented in the +feature's pure core + surfaced in the `ComputerField` helper text. + +**NOT done (correctly deferred):** a per-send `computerId` override (the MVP UI doesn't expose it; persisted per-conversation +suffices). No `chat.send` change. + +**Verification:** 795/795 tests green (50 files; +20 computer view-model); biome clean; `vite build` succeeds. `svelte-check` +reports **0 errors from the computer feature** — the only 11 errors are the pre-existing §2d `provider-retry` divergence. +Live probe NOT run (the backend `ssh` extension isn't live yet → `GET /computers` returns `[]` end-to-end; a live probe + +human confirm of the dropdown/badge/test should run once `ssh` is wired + the `provider-retry` divergence is merged). + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns diff --git a/src/App.svelte b/src/App.svelte index 9d06b9f..402c88d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -68,7 +68,7 @@ </script> {#if route.kind === "home"} - <WorkspacesHome store={workspaceStore} onNavigate={navigate} /> + <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} /> {:else} <App {store} /> {/if} diff --git a/src/app/App.svelte b/src/app/App.svelte index 18be3ea..78504bb 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -50,6 +50,16 @@ manifest as cwdLspManifest, } from "../features/cwd-lsp"; import { + ComputerField, + manifest as computerManifest, + type ComputerSaveResult, + type ComputerStatusResult, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, + type TestComputerResult, + } from "../features/computer"; + import { SystemPromptBuilder, type LoadSystemPrompt as LoadSystemPromptAlias, type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias, @@ -116,6 +126,7 @@ cacheWarmingManifest, cwdLspManifest, mcpManifest, + computerManifest, smartScrollManifest, settingsManifest, systemPromptManifest, @@ -311,6 +322,29 @@ : { ok: false, error: result.error }; } + // Adapt the store's computer results to the computer feature's ports. + async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> { + const result = await store.setComputer(computerId); + if (result === null) return null; + return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error }; + } + + const loadComputerStatus: LoadComputerStatus = async ( + alias: string, + ): Promise<ComputerStatusResult | null> => { + const result = await store.computerStatus(alias); + if (result === null) return null; + return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error }; + }; + + const testComputer: TestComputer = async ( + alias: string, + ): Promise<TestComputerResult | null> => { + const result = await store.testComputer(alias); + if (result === null) return null; + return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error }; + }; + async function loadMcpStatus(): Promise<McpStatusResult | null> { const result = await store.mcpStatus(); if (result === null) return null; @@ -493,6 +527,14 @@ {#key store.currentConversationId} <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} /> <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> + <ComputerField + computerId={store.computerId} + canEdit={true} + computers={store.computers} + save={saveComputer} + loadStatus={loadComputerStatus} + test={testComputer} + /> {/key} </div> {:else if kind === "lsp"} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index daba83f..1d69391 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -3,7 +3,10 @@ import type { ChatErrorMessage, CompactPercentResponse, CompactResponse, + ComputerListResponse, + ComputerStatusResponse, ConversationCompactedMessage, + ConversationComputerResponse, ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, @@ -18,6 +21,7 @@ import type { ReasoningEffort, ReasoningEffortResponse, SetCompactPercentRequest, + SetConversationComputerRequest, SetCwdRequest, SetModelRequest, SetReasoningEffortRequest, @@ -26,11 +30,12 @@ import type { SystemPromptTemplateResponse, SystemPromptVariable, SystemPromptVariablesResponse, + TestComputerResponse, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { ConversationStatus } from "@dispatch/wire"; +import type { ComputerEntry, ConversationStatus } from "@dispatch/wire"; import { untrack } from "svelte"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; @@ -68,6 +73,21 @@ export type CwdResult = | { readonly ok: true; readonly cwd: string | null } | { readonly ok: false; readonly error: string }; +/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */ +export type ComputerResult = + | { readonly ok: true; readonly computerId: string | null } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /computers/:alias/status` (the live connection state). */ +export type ComputerStatusResult = + | { readonly ok: true; readonly response: ComputerStatusResponse } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */ +export type TestComputerResult = + | { readonly ok: true; readonly response: TestComputerResponse } + | { readonly ok: false; readonly error: string }; + /** Outcome of `GET /conversations/:id/lsp`. */ export type LspResult = | { readonly ok: true; readonly response: LspStatusResponse } @@ -162,6 +182,38 @@ export interface AppStore { */ setCwd(cwd: string): Promise<CwdResult | null>; /** + * The workspace conversation's persisted computer (an SSH `Host` alias), or + * null when never set / local. Seeded from the backend on focus change. + */ + readonly computerId: string | null; + /** + * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`). + * Pass null to clear → the conversation inherits the workspace default → local. + * Works for a draft too (its id survives promotion). Not seen by the agent — a + * user-facing tool-execution target only. + */ + setComputer(computerId: string | null): Promise<ComputerResult | null>; + /** + * Every remote computer discovered from the user's `~/.ssh/config` + * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD + * (the user edits their ssh config to add one). Empty until the `ssh` + * extension lands. + */ + readonly computers: readonly ComputerEntry[]; + /** + * The live connection state of a computer (`GET /computers/:alias/status`): + * whether Dispatch currently holds an open SSH session to it. Returns null + * only if no alias is given (the focused conversation is local). Polled by the + * `ComputerField` while a computer is selected. + */ + computerStatus(alias: string): Promise<ComputerStatusResult | null>; + /** + * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens + * an SSH connection to the alias, runs a trivial command, then closes. `ok` is + * true on success; `error` carries the failure reason otherwise. + */ + testComputer(alias: string): Promise<TestComputerResult | null>; + /** * The workspace conversation's persisted reasoning effort, or null when never * set (the server then resolves turns at the default, `"high"`). */ @@ -304,6 +356,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state<ProtocolState>(protocolInitialState()); let models = $state<readonly string[]>([]); let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({}); + // Discovered SSH computers (`GET /computers`). Global (like `models`); empty + // until the `ssh` extension lands. Read-only — no CRUD (the user edits their + // `~/.ssh/config`). + let computers = $state<readonly ComputerEntry[]>([]); let activeModel = $state(DEFAULT_MODEL); let fatalError = $state<string | null>(null); @@ -424,6 +480,31 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + // The active conversation's persisted computer (SSH Host alias). Seeded on + // focus change; null = local / never set (inherits the workspace default). + let computerId = $state<string | null>(null); + + /** + * Refetch the workspace conversation's persisted computer into reactive state + * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false + * so it is a silent no-op (mirrors `refreshCwd` for a draft). + */ + async function refreshComputer(): Promise<void> { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's + // computer while the fetch is in flight (null renders as "Local"). + computerId = null; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`); + if (!res.ok) return; + const data = (await res.json()) as ConversationComputerResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) computerId = data.computerId ?? null; + } catch (err) { + reportError("Failed to load computer", err); + } + } + /** Refetch the workspace conversation's persisted model (works for a draft too). */ async function refreshModel(): Promise<void> { const id = workspaceConversationId(); @@ -657,6 +738,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshComputer(); void refreshReasoningEffort(); void refreshCompactPercent(); } @@ -823,6 +905,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { reportError("Failed to load model list", err); }); + // Fetch the discovered-computer catalog (global, like models). Empty until + // the `ssh` extension lands — a safe no-op until then (the selector shows + // "Local (none)" only). Non-fatal: a failure leaves an empty list. + void fetchImpl(`${httpBase}/computers`) + .then((res) => { + if (!res.ok) return { computers: [] } as ComputerListResponse; + return res.json() as Promise<ComputerListResponse>; + }) + .then((data) => { + computers = data?.computers ?? []; + }) + .catch((err) => { + reportError("Failed to load computer list", err); + }); + // Restore persisted tabs const persistedState = storageAdapter.load(); if (persistedState !== null && persistedState.tabs.length > 0) { @@ -847,6 +944,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); void refreshCwd(); + void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); @@ -877,6 +975,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); @@ -913,6 +1012,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get cwd(): string | null { return cwd; }, + get computerId(): string | null { + return computerId; + }, + get computers(): readonly ComputerEntry[] { + return computers; + }, get reasoningEffort(): ReasoningEffort | null { return reasoningEffort; }, @@ -957,6 +1062,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // surfaces (e.g. cache-warming) to its id. syncSubscriptions(); void refreshCwd(); + void refreshComputer(); void refreshReasoningEffort(); void refreshCompactPercent(); // Now send on the promoted store @@ -1001,6 +1107,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); @@ -1015,6 +1122,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); @@ -1097,6 +1205,81 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> { + const id = workspaceConversationId(); + const body: SetConversationComputerRequest = { computerId: computerIdValue }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/computer`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ConversationComputerResponse; + const next = data.computerId ?? null; + if (workspaceConversationId() === id) computerId = next; + return { ok: true, computerId: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set computer request failed", + }; + } + }, + + async computerStatus(alias: string): Promise<ComputerStatusResult | null> { + if (alias === "") return null; + try { + const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`, + }; + } + const status = (await res.json()) as ComputerStatusResponse; + return { ok: true, response: status }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Computer status request failed", + }; + } + }, + + async testComputer(alias: string): Promise<TestComputerResult | null> { + if (alias === "") return null; + try { + const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, { + method: "POST", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`, + }; + } + const response = (await res.json()) as TestComputerResponse; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Test computer request failed", + }; + } + }, + async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> { const id = workspaceConversationId(); const body: SetReasoningEffortRequest = { reasoningEffort: level }; diff --git a/src/features/computer/index.ts b/src/features/computer/index.ts new file mode 100644 index 0000000..656f6c2 --- /dev/null +++ b/src/features/computer/index.ts @@ -0,0 +1,31 @@ +export type { + Badge, + ComputerListResult, + ComputerSaveResult, + ComputerStatusResult, + ComputerStatusView, + ComputerView, + LoadComputerStatus, + LoadComputers, + SaveComputer, + TestComputer, + TestComputerResult, + TestResultView, +} from "./logic/view-model"; +export { + formatHost, + knownHostLabel, + summarizeComputers, + viewComputer, + viewComputerStatus, + viewComputers, + viewTestResult, +} from "./logic/view-model"; +export { default as ComputerField } from "./ui/ComputerField.svelte"; +export { default as ComputerSelect } from "./ui/ComputerSelect.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "computer", + description: "Per-conversation / per-workspace SSH computer selection + status", +} as const; diff --git a/src/features/computer/logic/view-model.test.ts b/src/features/computer/logic/view-model.test.ts new file mode 100644 index 0000000..d9b7f74 --- /dev/null +++ b/src/features/computer/logic/view-model.test.ts @@ -0,0 +1,167 @@ +import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract"; +import type { ComputerEntry } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + formatHost, + knownHostLabel, + summarizeComputers, + viewComputer, + viewComputerStatus, + viewComputers, + viewTestResult, +} from "./view-model"; + +function computer(overrides: Partial<ComputerEntry> = {}): ComputerEntry { + return { + alias: "buildbox", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/deploy/.ssh/id_ed25519", + knownHost: true, + usageCount: 0, + ...overrides, + }; +} + +describe("formatHost", () => { + it("is user@host with no port when port is the SSH default (22)", () => { + expect(formatHost(computer({ port: 22 }))).toBe("[email protected]"); + }); + + it("appends a non-default port", () => { + expect(formatHost(computer({ port: 2222 }))).toBe("[email protected]:2222"); + }); + + it("falls back to the alias when hostName is empty", () => { + expect(formatHost(computer({ hostName: "" }))).toBe("deploy@buildbox"); + }); +}); + +describe("knownHostLabel", () => { + it("is 'known host' when known", () => { + expect(knownHostLabel(true)).toBe("known host"); + }); + + it("is 'new host' when not known", () => { + expect(knownHostLabel(false)).toBe("new host"); + }); +}); + +describe("viewComputer", () => { + it("projects alias + hostSummary + identity + known-host label", () => { + const v = viewComputer(computer()); + expect(v.alias).toBe("buildbox"); + expect(v.hostSummary).toBe("[email protected]"); + expect(v.identityFile).toBe("/home/deploy/.ssh/id_ed25519"); + expect(v.knownHostLabel).toBe("known host"); + expect(v.knownHost).toBe(true); + }); + + it("preserves a null identityFile (default key)", () => { + const v = viewComputer(computer({ identityFile: null })); + expect(v.identityFile).toBeNull(); + }); +}); + +describe("viewComputers", () => { + it("maps each entry (and drops usageCount from the view)", () => { + const views = viewComputers([ + computer({ alias: "a", usageCount: 3 }), + computer({ alias: "b", hostName: "b.local", usageCount: 0 }), + ]); + expect(views).toHaveLength(2); + expect(views.at(0)?.alias).toBe("a"); + expect(views.at(1)?.hostSummary).toBe("[email protected]"); + }); +}); + +describe("summarizeComputers", () => { + it("is 'No computers discovered' for an empty list", () => { + expect(summarizeComputers([])).toBe("No computers discovered"); + }); + + it("is singular for one computer", () => { + expect(summarizeComputers([computer()])).toBe("1 computer"); + }); + + it("is plural for many", () => { + expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers"); + }); +}); + +describe("viewComputerStatus", () => { + function status( + state: ComputerStatusResponse["state"], + overrides: Partial<ComputerStatusResponse> = {}, + ): ComputerStatusResponse { + return { alias: "buildbox", state, knownHost: true, ...overrides }; + } + + it("connected → success badge, not busy, no error", () => { + const v = viewComputerStatus(status("connected")); + expect(v.statusLabel).toBe("Connected"); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("connecting → warning badge + busy (spinner), no error", () => { + const v = viewComputerStatus(status("connecting")); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); + + it("disconnected → neutral badge, NOT busy (a stable idle state)", () => { + const v = viewComputerStatus(status("disconnected")); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.badge).toBe("neutral"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("error → error badge, not busy, surfaces the reason", () => { + const v = viewComputerStatus(status("error", { error: "auth refused" })); + expect(v.statusLabel).toBe("Error"); + expect(v.badge).toBe("error"); + expect(v.busy).toBe(false); + expect(v.error).toBe("auth refused"); + }); + + it("error falls back to a default reason when the backend omits one", () => { + const v = viewComputerStatus(status("error")); + expect(v.error).toBe("Connection failed"); + }); + + it("carries the knownHost flag through", () => { + const v = viewComputerStatus(status("connected", { knownHost: false })); + expect(v.knownHost).toBe(false); + }); +}); + +describe("viewTestResult", () => { + it("ok=true → no error, 'Connection OK' label", () => { + const r = viewTestResult({ alias: "buildbox", ok: true } as TestComputerResponse); + expect(r.ok).toBe(true); + expect(r.error).toBeNull(); + expect(r.label).toBe("Connection OK"); + }); + + it("ok=false → surfaces the failure reason", () => { + const r = viewTestResult({ + alias: "buildbox", + ok: false, + error: "host unreachable", + } as TestComputerResponse); + expect(r.ok).toBe(false); + expect(r.error).toBe("host unreachable"); + expect(r.label).toBe("Failed"); + }); + + it("ok=false without a reason falls back to a default", () => { + const r = viewTestResult({ alias: "buildbox", ok: false } as TestComputerResponse); + expect(r.error).toBe("Connection failed"); + }); +}); diff --git a/src/features/computer/logic/view-model.ts b/src/features/computer/logic/view-model.ts new file mode 100644 index 0000000..944456b --- /dev/null +++ b/src/features/computer/logic/view-model.ts @@ -0,0 +1,184 @@ +import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract"; +import type { Computer, ComputerEntry } from "@dispatch/wire"; + +/** + * Pure core for the computer feature — zero DOM, zero effects, zero Svelte. + * + * A **computer** is a remote SSH target discovered from the user's `~/.ssh/config` + * (read-only — there is no CRUD store; the user edits their config to add one). + * The computer feature surfaces, per-conversation and per-workspace, WHICH + * computer a turn's tools execute on (the computer analog of `cwd`). It is a + * USER-facing control only: the computer is a tool-execution target forwarded to + * tools and NEVER part of the model prompt (so it does not affect prompt + * caching) — the agent never sees it. + * + * This module holds the pure logic: formatting a `Computer` for display, mapping a + * live `ComputerStatusResponse.state` to a display badge, and the one-line + * discovered-list summary. The effects (the HTTP list/get/status/test + + * per-conversation get/set) are INJECTED via the ports below; the composition root + * implements them. + */ + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). Mirrors cwd-lsp's ports. ────────────── + +/** Outcome of `PUT /conversations/:id/computer`; `null` when no real conversation is focused. */ +export type ComputerSaveResult = + | { readonly ok: true; readonly computerId: string | null } + | { readonly ok: false; readonly error: string }; + +export type SaveComputer = (computerId: string | null) => Promise<ComputerSaveResult | null>; + +/** Outcome of `GET /computers`; `null` when the list couldn't be loaded. */ +export type ComputerListResult = + | { readonly ok: true; readonly computers: readonly ComputerEntry[] } + | { readonly ok: false; readonly error: string }; + +export type LoadComputers = () => Promise<ComputerListResult | null>; + +/** Outcome of `GET /computers/:alias/status`; `null` when no alias / not loaded. */ +export type ComputerStatusResult = + | { readonly ok: true; readonly status: ComputerStatusResponse } + | { readonly ok: false; readonly error: string }; + +export type LoadComputerStatus = (alias: string) => Promise<ComputerStatusResult | null>; + +/** Outcome of `POST /computers/:alias/test`. */ +export type TestComputerResult = + | { readonly ok: true; readonly response: TestComputerResponse } + | { readonly ok: false; readonly error: string }; + +export type TestComputer = (alias: string) => Promise<TestComputerResult | null>; + +// ── Computer → display view ────────────────────────────────────────────────── + +export type Badge = "success" | "warning" | "error" | "neutral"; + +export interface ComputerView { + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** A compact `user@host:port` connection string (the resolved config values). */ + readonly hostSummary: string; + /** The resolved `IdentityFile`, or `null` = default `~/.ssh/id_*`. */ + readonly identityFile: string | null; + /** Short label for the known-host indicator, e.g. "known host" / "new host". */ + readonly knownHostLabel: string; + /** Whether the host's key is already in `~/.ssh/known_hosts`. */ + readonly knownHost: boolean; +} + +/** + * Format a computer's connection target as `user@host:port`, omitting the port + * when it is the SSH default (22) so the summary stays compact (mirrors how a user + * reads an ssh config `Host` block). `hostName` falls back to the alias itself + * (the backend resolves this, but this is defensive). + */ +export function formatHost(computer: Computer): string { + const host = computer.hostName || computer.alias; + const port = computer.port === 22 ? "" : `:${computer.port}`; + return `${computer.user}@${host}${port}`; +} + +/** The display label for the known-host indicator. */ +export function knownHostLabel(knownHost: boolean): string { + return knownHost ? "known host" : "new host"; +} + +export function viewComputer(computer: Computer): ComputerView { + return { + alias: computer.alias, + hostSummary: formatHost(computer), + identityFile: computer.identityFile, + knownHostLabel: knownHostLabel(computer.knownHost), + knownHost: computer.knownHost, + }; +} + +export function viewComputers(computers: readonly ComputerEntry[]): readonly ComputerView[] { + return computers.map(viewComputer); +} + +/** + * A one-line summary of the discovered list, e.g. "3 computers" / "No computers + * discovered" (the latter is the expected state until the `ssh` extension lands). + */ +export function summarizeComputers(computers: readonly ComputerEntry[]): string { + if (computers.length === 0) return "No computers discovered"; + return `${computers.length} computer${computers.length === 1 ? "" : "s"}`; +} + +// ── Connection status → display view ────────────────────────────────────────── + +export interface ComputerStatusView { + readonly alias: string; + readonly state: ComputerStatusResponse["state"]; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Whether the host's key is already in `~/.ssh/known_hosts`. */ + readonly knownHost: boolean; +} + +/** + * Map a computer's live connection state to a display label + badge + busy flag. + * Mirrors the LSP/MCP status→badge pattern (each feature owns its own mapping — + * the enums differ, so they are not shared). Exhaustive vs the contract: + * `connected`→success, `connecting`→warning+busy, `disconnected`→neutral (a stable + * idle state, NOT busy), `error`→error. + */ +export function viewComputerStatus(status: ComputerStatusResponse): ComputerStatusView { + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (status.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + alias: status.alias, + state: status.state, + statusLabel, + badge, + busy, + error: status.state === "error" ? (status.error ?? "Connection failed") : null, + knownHost: status.knownHost, + }; +} + +// ── Test-connection result → display view ─────────────────────────────────── + +export interface TestResultView { + readonly alias: string; + readonly ok: boolean; + /** The failure reason when `ok` is false, else null. */ + readonly error: string | null; + /** A short result label, e.g. "Connection OK" / "Failed". */ + readonly label: string; +} + +export function viewTestResult(response: TestComputerResponse): TestResultView { + return { + alias: response.alias, + ok: response.ok, + error: response.ok ? null : (response.error ?? "Connection failed"), + label: response.ok ? "Connection OK" : "Failed", + }; +} diff --git a/src/features/computer/ui/ComputerField.svelte b/src/features/computer/ui/ComputerField.svelte new file mode 100644 index 0000000..be3ce9c --- /dev/null +++ b/src/features/computer/ui/ComputerField.svelte @@ -0,0 +1,195 @@ +<script lang="ts"> + import type { ComputerStatusResponse } from "@dispatch/transport-contract"; + import ComputerSelect from "./ComputerSelect.svelte"; + import { + viewComputerStatus, + type ComputerStatusView, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, +} from "../logic/view-model"; + import type { ComputerEntry } from "@dispatch/wire"; + import { untrack } from "svelte"; + + let { + computerId, + canEdit, + computers, + save, + loadStatus, + test, + }: { + /** The active conversation's persisted computer alias, or null (local/inherit). */ + computerId: string | null; + /** Whether a real conversation is focused (a draft can't persist yet). */ + canEdit: boolean; + /** Discovered computers from `GET /computers` (read-only). */ + computers: readonly ComputerEntry[]; + save: SaveComputer; + loadStatus: LoadComputerStatus; + test: TestComputer; + } = $props(); + + // ── Save: selecting a dropdown option persists immediately (PUT /computer). ── + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + + async function select(computerId: string | null) { + if (saving || !canEdit) return; + saving = true; + error = null; + justSaved = false; + const result = await save(computerId); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + } + } + + // ── Connection status: poll the selected computer's live state. Owned + ── + // disposed here (never leaks across conversations — the field re-mounts per + // conversation via the {#key} in App.svelte, like CwdField). No poll while + // local (no alias) or while a draft can't persist. + let statusView = $state<ComputerStatusView | null>(null); + let statusError = $state<string | null>(null); + + async function refreshStatus() { + const alias = untrack(() => computerId); + if (alias === null) { + statusView = null; + statusError = null; + return; + } + const result = await loadStatus(alias); + if (result === null) return; + if (result.ok) { + statusView = viewComputerStatus(result.status); + statusError = null; + } else { + statusView = null; + statusError = result.error; + } + } + + // Re-fetch on mount + whenever the selected alias changes (incl. after a save). + $effect(() => { + void computerId; + void refreshStatus(); + }); + + // Poll the status while a computer is selected. `connecting` is transient, so + // a faster cadence helps it flip to `connected`; a connected host is stable. + const POLL_MS = 4000; + $effect(() => { + const alias = computerId; + if (alias === null) return; + const handle = setInterval(() => void refreshStatus(), POLL_MS); + return () => clearInterval(handle); + }); + + // ── Test connection: one-shot probe (POST /computers/:alias/test). ────────── + let testing = $state(false); + let testResult = $state<{ ok: boolean; error: string | null } | null>(null); + + async function runTest() { + const alias = untrack(() => computerId); + if (alias === null || testing) return; + testing = true; + testResult = null; + const result = await test(alias); + testing = false; + if (result === null) return; + testResult = result.ok + ? { ok: true, error: null } + : { ok: false, error: result.error }; + } + + const badgeClass = $derived.by(() => { + const b = statusView?.badge ?? "neutral"; + switch (b) { + case "success": + return "badge-success"; + case "warning": + return "badge-warning"; + case "error": + return "badge-error"; + default: + return "badge-ghost"; + } + }); +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Computer (SSH)</span> + <div class="flex items-center gap-2"> + <ComputerSelect + value={computerId} + {computers} + disabled={!canEdit || saving} + onSelect={select} + /> + {#if saving} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + {#if !canEdit} + <p class="text-xs opacity-60">Start or open a conversation to set its computer.</p> + {:else if computerId !== null} + <!-- Connection status badge + Test affordance for the selected computer. --> + <div class="flex flex-wrap items-center gap-2"> + {#if statusView} + <span class="badge badge-sm {badgeClass}" title={statusView.error ?? statusView.statusLabel}> + {#if statusView.busy} + <span class="loading loading-spinner loading-[10px]"></span> + {/if} + {statusView.statusLabel} + </span> + {:else if statusError} + <span class="badge badge-sm badge-error" title={statusError}>Status error</span> + {:else} + <span class="badge badge-sm badge-ghost">—</span> + {/if} + + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={testing} + onclick={runTest} + > + {#if testing} + <span class="loading loading-spinner loading-[10px]"></span> + {:else} + Test + {/if} + </button> + + {#if testResult} + <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}"> + {testResult.ok ? "Connection OK" : testResult.error ?? "Failed"} + </span> + {/if} + </div> + {#if statusView?.error} + <p class="text-xs text-error">{statusView.error}</p> + {/if} + {:else if computers.length === 0} + <p class="text-xs opacity-50"> + No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer. + </p> + {:else if justSaved && !error} + <p class="text-xs text-success">Saved.</p> + {/if} + + {#if error} + <p class="text-xs text-error">{error}</p> + {/if} + + <p class="text-xs opacity-50"> + Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. Not seen by the agent. + </p> +</div> diff --git a/src/features/computer/ui/ComputerSelect.svelte b/src/features/computer/ui/ComputerSelect.svelte new file mode 100644 index 0000000..d693140 --- /dev/null +++ b/src/features/computer/ui/ComputerSelect.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import type { ComputerEntry } from "@dispatch/wire"; + + let { + value, + computers, + disabled = false, + onSelect, + }: { + /** The currently selected computer alias, or null for "Local (none)". */ + value: string | null; + /** Discovered computers from `GET /computers` (read-only). */ + computers: readonly ComputerEntry[]; + disabled?: boolean; + onSelect: (computerId: string | null) => void; + } = $props(); + + // A `<select>` value is a string; map "" ↔ null (Local). The chosen option's + // value is the alias, with "" meaning "clear / local". + const selectValue = $derived(value ?? ""); + + function onChange(e: Event) { + const v = (e.currentTarget as HTMLSelectElement).value; + onSelect(v === "" ? null : v); + } +</script> + +<select + class="select select-bordered select-sm w-full font-mono text-xs" + value={selectValue} + disabled={disabled} + onchange={onChange} + aria-label="Computer" +> + <option value="">Local (none)</option> + {#each computers as c (c.alias)} + <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option> + {/each} +</select> diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 97fc2e2..6ebfee1 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -1,6 +1,7 @@ import type { DeleteWorkspaceResponse, EnsureWorkspaceRequest, + SetWorkspaceDefaultComputerRequest, SetWorkspaceDefaultCwdRequest, SetWorkspaceTitleRequest, Workspace, @@ -23,6 +24,7 @@ import type { * - `GET /workspaces/:id` (404 → null) → get * - `PUT /workspaces/:id/title` → rename * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd + * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = @@ -35,6 +37,7 @@ export interface WorkspaceHttp { get(id: string): Promise<Workspace | null>; setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; + setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -118,6 +121,26 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set default computer failed", + }; + } + }, + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 52f7025..a4ed19c 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -36,6 +36,7 @@ describe("pageTitle", () => { id, title, defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, conversationCount: 0, diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 0f662dc..0cf4ce2 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -21,6 +21,8 @@ export interface WorkspaceStore { rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>; /** Set/clear a workspace's default cwd. */ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; + /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ + setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -71,6 +73,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { + const result = await http.setDefaultComputer(id, computerId); + if (result.ok) void this.refresh(); + return result; + }, + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { const result = await http.delete(id); if (result.ok) void this.refresh(); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 6348bb9..4f846bf 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -1,18 +1,22 @@ <script lang="ts"> - import type { WorkspaceEntry } from "@dispatch/wire"; + import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; import { untrack } from "svelte"; import type { WorkspaceStore } from "../store.svelte"; import { relativeTime } from "../logic/view-model"; import { workspacePath } from "../logic/route"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; let { ws, store, onNavigate, + computers, }: { ws: WorkspaceEntry; store: WorkspaceStore; onNavigate: (path: string) => void; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; } = $props(); // ── Title: double-click to rename inline ────────────────────────────────── @@ -64,6 +68,21 @@ if (!result.ok) cwdError = result.error; } + // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── + let savingComputer = $state(false); + let computerError = $state<string | null>(null); + + async function saveComputer(computerId: string | null): Promise<void> { + if (savingComputer) return; + // No-op when unchanged (the select only fires on a real change, but guard). + if (computerId === (ws.defaultComputerId ?? null)) return; + savingComputer = true; + computerError = null; + const result = await store.setDefaultComputer(ws.id, computerId); + savingComputer = false; + if (!result.ok) computerError = result.error; + } + // ── Delete ───────────────────────────────────────────────────────────────── let deleting = $state(false); @@ -159,6 +178,19 @@ </button> </div> + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> + <ComputerSelect + value={ws.defaultComputerId} + {computers} + disabled={savingComputer} + onSelect={saveComputer} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + <div class="flex justify-start"> <a class="btn" @@ -177,4 +209,10 @@ {:else if !cwdDirty && !ws.defaultCwd} <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> {/if} + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} </li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index 385856d..48b12b3 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -11,6 +11,7 @@ function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { id: "my-ws", title: "My Workspace", defaultCwd: null, + defaultComputerId: null, createdAt: 1, lastActivityAt: 2, conversationCount: 3, @@ -33,6 +34,12 @@ function fakeStore() { value: fakeEntry({ id, defaultCwd }), }), ), + setDefaultComputer: vi.fn( + async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId: computerId }), + }), + ), remove: vi.fn( async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ ok: true, @@ -47,7 +54,7 @@ describe("WorkspaceCard", () => { const store = fakeStore() as unknown as WorkspaceStore; const onNavigate = vi.fn(); render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate }, + props: { ws: fakeEntry(), store, onNavigate, computers: [] }, }); expect(screen.getByText("My Workspace")).toBeInTheDocument(); expect(screen.getByText("/my-ws")).toBeInTheDocument(); @@ -57,7 +64,9 @@ describe("WorkspaceCard", () => { it("double-clicking the title reveals an edit input", async () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate: vi.fn() } }); + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); await user.dblClick(screen.getByText("My Workspace")); expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); @@ -66,7 +75,9 @@ describe("WorkspaceCard", () => { it("renames via the store on Enter", async () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate: vi.fn() } }); + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); await user.dblClick(screen.getByText("My Workspace")); const input = screen.getByLabelText("Workspace title"); @@ -80,7 +91,7 @@ describe("WorkspaceCard", () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn() }, + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, }); const input = screen.getByLabelText("Default working directory"); @@ -99,7 +110,7 @@ describe("WorkspaceCard", () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn() }, + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, }); const input = screen.getByLabelText("Default working directory"); @@ -113,7 +124,7 @@ describe("WorkspaceCard", () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; const onNavigate = vi.fn(); - render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate } }); + render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } }); await user.click(screen.getByRole("link", { name: "Open" })); expect(onNavigate).toHaveBeenCalledWith("/my-ws"); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 5894f0a..bdfd023 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,10 +1,16 @@ <script lang="ts"> import { onMount } from "svelte"; + import type { ComputerEntry } from "@dispatch/wire"; import type { WorkspaceStore } from "../store.svelte"; import { isValidSlug, workspacePath } from "../logic/route"; import WorkspaceCard from "./WorkspaceCard.svelte"; - let { store, onNavigate }: { store: WorkspaceStore; onNavigate: (path: string) => void } = $props(); + let { + store, + onNavigate, + computers, + }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } = + $props(); onMount(() => { void store.refresh(); @@ -78,7 +84,7 @@ {:else} <ul class="flex flex-col gap-2"> {#each store.list as ws (ws.id)} - <WorkspaceCard {ws} {store} {onNavigate} /> + <WorkspaceCard {ws} {store} {onNavigate} {computers} /> {/each} </ul> {/if} |
