summaryrefslogtreecommitdiffhomepage
path: root/src/features/computer/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 17:09:57 +0900
committerAdam Malczewski <[email protected]>2026-06-25 17:09:57 +0900
commit6b0bb19e914a3bbaeca198ac1627d698c3a13a76 (patch)
tree5f9a9c0856e0eefa908963d6e0d650b2853808a5 /src/features/computer/logic
parent38db3827870960f466be89afbc49f91238d46144 (diff)
downloaddispatch-web-6b0bb19e914a3bbaeca198ac1627d698c3a13a76.tar.gz
dispatch-web-6b0bb19e914a3bbaeca198ac1627d698c3a13a76.zip
feat(computer): SSH computer selection + status + workspace default (handoff #2)
Mirrors the cwd/workspace UI for the SSH-computer feature: - New feature library src/features/computer/: - logic/view-model.ts (pure): viewComputer/viewComputerStatus/viewTestResult/ summarizeComputers/formatHost/knownHostLabel + state->badge for the 4 ComputerStatusResponse states + SaveComputer/LoadComputerStatus/ TestComputer/LoadComputers ports. 20 view-model tests. - ui/ComputerField.svelte: per-conversation selector (dropdown + connection-status badge + Test-connection, polling the selected alias). - ui/ComputerSelect.svelte: reusable Local/computers dropdown, shared with the workspace default-computer control. - AppStore: computerId state + refreshComputer (at every focus site, parallel to refreshCwd) + setComputer (PUT /conversations/:id/computer, null=clear) + global computers catalog (GET /computers on boot, like models) + computerStatus(alias) + testComputer(alias). chat.send UNCHANGED (resolved server-side like cwd). - App.svelte: ComputerField in the Model sidebar view next to CwdField; adapted ports wrap the store. - workspaces: setDefaultComputer on WorkspaceHttp+WorkspaceStore (PUT /workspaces/:id/default-computer); default-computer selector in WorkspaceCard (reuses ComputerSelect); router passes store.computers through. - Re-mirrored .dispatch/transport-contract.reference.md (Computers section + ChatRequest.computerId); updated .dispatch/wire.reference.md (Computer/ ComputerEntry/defaultComputerId + the provider-retry divergence note from handoff #1); GLOSSARY + backend-handoff.md (handoff #2, §2e). Transparency invariant: the computer is USER-facing only (a tool-execution target, never part of the model prompt); the agent never sees it. Verify: 795/795 tests green; biome clean; vite build succeeds; 0 typecheck errors from the computer feature. (11 pre-existing svelte-check errors remain from the open §2d provider-retry divergence — backend feature/ssh-support still lacks TurnProviderRetryEvent; not from this feature.)
Diffstat (limited to 'src/features/computer/logic')
-rw-r--r--src/features/computer/logic/view-model.test.ts167
-rw-r--r--src/features/computer/logic/view-model.ts184
2 files changed, 351 insertions, 0 deletions
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",
+ };
+}