summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 22:45:51 +0900
committerAdam Malczewski <[email protected]>2026-06-25 22:45:51 +0900
commit1285564f12238b22f6b39b9f3fbcecaca8456911 (patch)
tree9d8ca532969a5a11ee61b5c42135ac6f54159183
parentc5ea2232f117adda740c7e3b8366e9f10f14d3cb (diff)
parentcdce5197abcf0f5b0576e847a701d3a317384a65 (diff)
downloaddispatch-web-1285564f12238b22f6b39b9f3fbcecaca8456911.tar.gz
dispatch-web-1285564f12238b22f6b39b9f3fbcecaca8456911.zip
Merge branch 'feature/ssh-support' into dev
# Conflicts: # src/features/workspaces/ui/WorkspaceCard.svelte # src/features/workspaces/ui/WorkspaceCard.test.ts # src/features/workspaces/ui/WorkspacesHome.svelte
-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.md114
-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.svelte209
-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.svelte42
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts24
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte10
18 files changed, 1232 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..d536280 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,16 +5,18 @@
> **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 (§2d RESOLVED — backend merged `dev` into `feature/ssh-support`, merge `de022ce`;
+`TurnProviderRetryEvent`/`provider-retry` is now present alongside the SSH types; FE re-synced both `file:` deps +
+`bun run typecheck` is GREEN — 0 errors — with ZERO further FE code changes, exactly as predicted. The full SSH
+computer feature (handoff #2, §2e) + the wire-type break (handoff #1) are unchanged; the merge only added
+`provider-retry` on top. FE is now fully green: typecheck 0/0, 795/795 tests, biome clean, build OK)._
**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). The SSH-divergence
+(§2d) is RESOLVED.
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 +30,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 +348,104 @@ effect + re-render churn). A live ticking countdown (5,4,3,2,1…) could be adde
---
+## 2d. SSH support — handoff #1 (wire types) → **CONSUMED ✅ (provider-retry divergence RESOLVED)**
+
+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.
+
+### `provider-retry` divergence → **RESOLVED ✅ (backend merge `de022ce`)**
+
+**Was:** the backend `feature/ssh-support` branch (cut from `8a74335`) was MISSING `TurnProviderRetryEvent` /
+`provider-retry` (on `dev`), causing 11 FE typecheck errors. **Resolved:** backend merged `dev` into
+`feature/ssh-support` (merge commit `de022ce`, in the shared `../backend` worktree); `packages/wire/dist/index.d.ts`
+now exports `TurnProviderRetryEvent` (AgentEvent union line 231 + interface line 384) ALONGSIDE the SSH types
+(`Workspace.defaultComputerId`, `Computer`). Backend post-merge: `tsc -b` EXIT 0, biome clean, 1730 vitest pass.
+The auto-merge was clean — `computerId` threading + retry-with-backoff coexist.
+
+**FE (DONE, zero code changes):** re-synced both `file:` deps (`bun install`); `node_modules/@dispatch/wire` +
+`@dispatch/transport-contract` both resolve `TurnProviderRetryEvent` (the transport-contract re-export confirmed).
+`bun run typecheck` is now **GREEN — 0 errors, 0 warnings** (the 11 cleared with NO FE code changes; the
+`provider-retry` consumption was already complete + tested). Full suite: 795/795 tests, biome clean, build OK.
+
+### 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; fully green post §2d merge)**
+
+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 pre-existing §2d `provider-retry` divergence — 11 errors — is
+now RESOLVED via the backend `dev`→`feature/ssh-support` merge `de022ce`; `bun run typecheck` is fully GREEN.)
+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..ce19b4d
--- /dev/null
+++ b/src/features/computer/ui/ComputerField.svelte
@@ -0,0 +1,209 @@
+<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).
+ // Clear the test result so a stale ✓ from alias A doesn't persist for alias B.
+ $effect(() => {
+ void computerId;
+ testResult = null;
+ 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 };
+ // Refresh the connection-status badge so it reflects the post-test state
+ // (clears a stale "connecting" spinner caught by the poll mid-test).
+ void refreshStatus();
+ }
+
+ 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}
+ title={testResult
+ ? testResult.ok
+ ? "Connection OK"
+ : testResult.error ?? "Failed"
+ : "Test connection"}
+ >
+ {#if testing}
+ <span class="loading loading-spinner loading-[10px]"></span>
+ {:else if testResult?.ok}
+ <span class="text-success">✓</span>
+ {:else if testResult}
+ <span class="text-error">✗</span>
+ {:else}
+ Test
+ {/if}
+ </button>
+
+ {#if testResult}
+ <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
+ {testResult.ok ? "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 81d6fe3..ff8a8ea 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -1,16 +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 ──────────────────────────────────
@@ -62,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);
@@ -153,6 +174,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"
@@ -169,4 +203,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 0264d5f..f6ea432 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,
@@ -46,7 +53,7 @@ describe("WorkspaceCard", () => {
it("renders the title, slug, and an Open link", () => {
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
- props: { ws: fakeEntry(), store },
+ props: { ws: fakeEntry(), store, onNavigate, computers: [] },
});
expect(screen.getByText("My Workspace")).toBeInTheDocument();
expect(screen.getByText("/my-ws")).toBeInTheDocument();
@@ -56,7 +63,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 } });
+ 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");
@@ -65,7 +74,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 } });
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
await user.dblClick(screen.getByText("My Workspace"));
const input = screen.getByLabelText("Workspace title");
@@ -79,7 +90,7 @@ describe("WorkspaceCard", () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store },
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
});
const input = screen.getByLabelText("Default working directory");
@@ -98,7 +109,7 @@ describe("WorkspaceCard", () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store },
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
});
const input = screen.getByLabelText("Default working directory");
@@ -110,7 +121,8 @@ describe("WorkspaceCard", () => {
it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, { props: { ws: fakeEntry(), store } });
+ const onNavigate = vi.fn();
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
const open = screen.getByRole("link", { name: "Open" });
// Native new-tab link: href points at the workspace, and target="_blank"
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index f71c3ba..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} />
+ <WorkspaceCard {ws} {store} {onNavigate} {computers} />
{/each}
</ul>
{/if}