From 6b0bb19e914a3bbaeca198ac1627d698c3a13a76 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 17:09:57 +0900 Subject: feat(computer): SSH computer selection + status + workspace default (handoff #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) --- src/features/computer/index.ts | 31 ++++ src/features/computer/logic/view-model.test.ts | 167 +++++++++++++++++++ src/features/computer/logic/view-model.ts | 184 +++++++++++++++++++++ src/features/computer/ui/ComputerField.svelte | 195 +++++++++++++++++++++++ src/features/computer/ui/ComputerSelect.svelte | 39 +++++ src/features/workspaces/adapter/http.ts | 23 +++ src/features/workspaces/logic/view-model.test.ts | 1 + src/features/workspaces/store.svelte.ts | 8 + src/features/workspaces/ui/WorkspaceCard.svelte | 40 ++++- src/features/workspaces/ui/WorkspaceCard.test.ts | 23 ++- src/features/workspaces/ui/WorkspacesHome.svelte | 10 +- 11 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 src/features/computer/index.ts create mode 100644 src/features/computer/logic/view-model.test.ts create mode 100644 src/features/computer/logic/view-model.ts create mode 100644 src/features/computer/ui/ComputerField.svelte create mode 100644 src/features/computer/ui/ComputerSelect.svelte (limited to 'src/features') 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 { + 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("deploy@10.0.0.5"); + }); + + it("appends a non-default port", () => { + expect(formatHost(computer({ port: 2222 }))).toBe("deploy@10.0.0.5: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("deploy@10.0.0.5"); + 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("deploy@b.local"); + }); +}); + +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 { + 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; + +/** 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; + +/** 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; + +/** 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; + +// ── 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 @@ + + +
+ Computer (SSH) +
+ + {#if saving} + + {/if} +
+ + {#if !canEdit} +

Start or open a conversation to set its computer.

+ {:else if computerId !== null} + +
+ {#if statusView} + + {#if statusView.busy} + + {/if} + {statusView.statusLabel} + + {:else if statusError} + Status error + {:else} + + {/if} + + + + {#if testResult} + + {testResult.ok ? "Connection OK" : testResult.error ?? "Failed"} + + {/if} +
+ {#if statusView?.error} +

{statusView.error}

+ {/if} + {:else if computers.length === 0} +

+ No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer. +

+ {:else if justSaved && !error} +

Saved.

+ {/if} + + {#if error} +

{error}

+ {/if} + +

+ Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. Not seen by the agent. +

+
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 @@ + + + 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 = @@ -35,6 +37,7 @@ export interface WorkspaceHttp { get(id: string): Promise; setTitle(id: string, title: string): Promise>; setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + setDefaultComputer(id: string, computerId: string | null): Promise>; delete(id: string): Promise>; } @@ -118,6 +121,26 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async setDefaultComputer(id, computerId): Promise> { + 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> { 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>; /** Set/clear a workspace's default cwd. */ setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ + setDefaultComputer(id: string, computerId: string | null): Promise>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise>; } @@ -71,6 +73,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setDefaultComputer(id, computerId): Promise> { + const result = await http.setDefaultComputer(id, computerId); + if (result.ok) void this.refresh(); + return result; + }, + async remove(id): Promise> { 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 @@