From 6b0bb19e914a3bbaeca198ac1627d698c3a13a76 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
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/ui/ComputerField.svelte | 195 ++++++++++++++++++++++++++
 1 file changed, 195 insertions(+)
 create mode 100644 src/features/computer/ui/ComputerField.svelte

(limited to 'src/features/computer/ui/ComputerField.svelte')

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 @@
+<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).
+	$effect(() => {
+		void computerId;
+		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 };
+	}
+
+	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}
+			>
+				{#if testing}
+					<span class="loading loading-spinner loading-[10px]"></span>
+				{:else}
+					Test
+				{/if}
+			</button>
+
+			{#if testResult}
+				<span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
+					{testResult.ok ? "Connection 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>
-- 
cgit v1.2.3


From cdce5197abcf0f5b0576e847a701d3a317384a65 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Thu, 25 Jun 2026 22:00:38 +0900
Subject: fix(computer): show ✓ check after a successful Test (not a stuck
 spinner)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

When the user clicks Test, the button now transitions spinner → ✓ (green) on
success / ✗ (red) on failure (persisting until the next test or computer
change), instead of reverting to plain 'Test' text with no success indicator.

Also refresh the connection-status badge immediately after a test completes, so
a stale 'connecting' spinner caught by the 4s poll mid-test is cleared and the
badge reflects the real post-test state. Clear the test result when the selected
computer changes so a stale ✓ from alias A doesn't persist for alias B.
---
 src/features/computer/ui/ComputerField.svelte | 16 +++++++++++++++-
 1 file changed, 15 insertions(+), 1 deletion(-)

(limited to 'src/features/computer/ui/ComputerField.svelte')

diff --git a/src/features/computer/ui/ComputerField.svelte b/src/features/computer/ui/ComputerField.svelte
index be3ce9c..ce19b4d 100644
--- a/src/features/computer/ui/ComputerField.svelte
+++ b/src/features/computer/ui/ComputerField.svelte
@@ -76,8 +76,10 @@
 	}
 
 	// 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();
 	});
 
@@ -106,6 +108,9 @@
 		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(() => {
@@ -160,9 +165,18 @@
 				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}
@@ -170,7 +184,7 @@
 
 			{#if testResult}
 				<span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
-					{testResult.ok ? "Connection OK" : testResult.error ?? "Failed"}
+					{testResult.ok ? "OK" : testResult.error ?? "Failed"}
 				</span>
 			{/if}
 		</div>
-- 
cgit v1.2.3

