summaryrefslogtreecommitdiffhomepage
path: root/src/features/computer
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/computer')
-rw-r--r--src/features/computer/index.ts42
-rw-r--r--src/features/computer/logic/view-model.test.ts266
-rw-r--r--src/features/computer/logic/view-model.ts170
-rw-r--r--src/features/computer/ui/ComputerField.svelte410
-rw-r--r--src/features/computer/ui/ComputerSelect.svelte60
5 files changed, 475 insertions, 473 deletions
diff --git a/src/features/computer/index.ts b/src/features/computer/index.ts
index 656f6c2..05e56cc 100644
--- a/src/features/computer/index.ts
+++ b/src/features/computer/index.ts
@@ -1,31 +1,31 @@
export type {
- Badge,
- ComputerListResult,
- ComputerSaveResult,
- ComputerStatusResult,
- ComputerStatusView,
- ComputerView,
- LoadComputerStatus,
- LoadComputers,
- SaveComputer,
- TestComputer,
- TestComputerResult,
- TestResultView,
+ Badge,
+ ComputerListResult,
+ ComputerSaveResult,
+ ComputerStatusResult,
+ ComputerStatusView,
+ ComputerView,
+ LoadComputerStatus,
+ LoadComputers,
+ SaveComputer,
+ TestComputer,
+ TestComputerResult,
+ TestResultView,
} from "./logic/view-model";
export {
- formatHost,
- knownHostLabel,
- summarizeComputers,
- viewComputer,
- viewComputerStatus,
- viewComputers,
- viewTestResult,
+ 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",
+ 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
index d9b7f74..45d459e 100644
--- a/src/features/computer/logic/view-model.test.ts
+++ b/src/features/computer/logic/view-model.test.ts
@@ -2,166 +2,166 @@ import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/tra
import type { ComputerEntry } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
- formatHost,
- knownHostLabel,
- summarizeComputers,
- viewComputer,
- viewComputerStatus,
- viewComputers,
- viewTestResult,
+ 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,
- };
+ 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("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("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");
- });
+ 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 'known host' when known", () => {
+ expect(knownHostLabel(true)).toBe("known host");
+ });
- it("is 'new host' when not known", () => {
- expect(knownHostLabel(false)).toBe("new 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();
- });
+ 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]");
- });
+ 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 '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 singular for one computer", () => {
+ expect(summarizeComputers([computer()])).toBe("1 computer");
+ });
- it("is plural for many", () => {
- expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers");
- });
+ 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);
- });
+ 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");
- });
+ 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
index 944456b..d489fb5 100644
--- a/src/features/computer/logic/view-model.ts
+++ b/src/features/computer/logic/view-model.ts
@@ -24,29 +24,29 @@ import type { Computer, ComputerEntry } from "@dispatch/wire";
/** 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 };
+ | { 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 };
+ | { 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 };
+ | { 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 };
+ | { readonly ok: true; readonly response: TestComputerResponse }
+ | { readonly ok: false; readonly error: string };
export type TestComputer = (alias: string) => Promise<TestComputerResult | null>;
@@ -55,16 +55,16 @@ export type TestComputer = (alias: string) => Promise<TestComputerResult | null>
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;
+ /** 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;
}
/**
@@ -74,28 +74,28 @@ export interface ComputerView {
* (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}`;
+ 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";
+ 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,
- };
+ 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);
+ return computers.map(viewComputer);
}
/**
@@ -103,23 +103,23 @@ export function viewComputers(computers: readonly ComputerEntry[]): readonly Com
* 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"}`;
+ 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;
+ 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;
}
/**
@@ -130,55 +130,55 @@ export interface ComputerStatusView {
* 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,
- };
+ 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;
+ 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",
- };
+ 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
index ce19b4d..6b54c88 100644
--- a/src/features/computer/ui/ComputerField.svelte
+++ b/src/features/computer/ui/ComputerField.svelte
@@ -1,209 +1,211 @@
<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";
- }
- });
+ 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>
+ <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
index d693140..c580b60 100644
--- a/src/features/computer/ui/ComputerSelect.svelte
+++ b/src/features/computer/ui/ComputerSelect.svelte
@@ -1,39 +1,39 @@
<script lang="ts">
- import type { ComputerEntry } from "@dispatch/wire";
+ 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();
+ 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 ?? "");
+ // 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);
- }
+ 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"
+ class="select select-bordered select-sm w-full font-mono text-xs"
+ value={selectValue}
+ {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}
+ <option value="">Local (none)</option>
+ {#each computers as c (c.alias)}
+ <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option>
+ {/each}
</select>