summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 17:09:57 +0900
committerAdam Malczewski <[email protected]>2026-06-25 17:09:57 +0900
commit6b0bb19e914a3bbaeca198ac1627d698c3a13a76 (patch)
tree5f9a9c0856e0eefa908963d6e0d650b2853808a5
parent38db3827870960f466be89afbc49f91238d46144 (diff)
downloaddispatch-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.md100
-rw-r--r--.dispatch/wire.reference.md70
-rw-r--r--GLOSSARY.md2
-rw-r--r--backend-handoff.md123
-rw-r--r--src/App.svelte2
-rw-r--r--src/app/App.svelte42
-rw-r--r--src/app/store.svelte.ts185
-rw-r--r--src/features/computer/index.ts31
-rw-r--r--src/features/computer/logic/view-model.test.ts167
-rw-r--r--src/features/computer/logic/view-model.ts184
-rw-r--r--src/features/computer/ui/ComputerField.svelte195
-rw-r--r--src/features/computer/ui/ComputerSelect.svelte39
-rw-r--r--src/features/workspaces/adapter/http.ts23
-rw-r--r--src/features/workspaces/logic/view-model.test.ts1
-rw-r--r--src/features/workspaces/store.svelte.ts8
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte40
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts23
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte10
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}