diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:13:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:13:31 +0900 |
| commit | 2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch) | |
| tree | 94e1923180ae38d571d34b578afecb0a18913c24 /src/features/computer | |
| parent | 80f99665034a0e510300793205c162fc7a46769f (diff) | |
| parent | 08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff) | |
| download | dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# src/app/App.svelte
# src/app/store.svelte.ts
# src/app/store.test.ts
# src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/computer')
| -rw-r--r-- | src/features/computer/index.ts | 42 | ||||
| -rw-r--r-- | src/features/computer/logic/view-model.test.ts | 266 | ||||
| -rw-r--r-- | src/features/computer/logic/view-model.ts | 170 | ||||
| -rw-r--r-- | src/features/computer/ui/ComputerField.svelte | 410 | ||||
| -rw-r--r-- | src/features/computer/ui/ComputerSelect.svelte | 60 |
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> |
