summaryrefslogtreecommitdiffhomepage
path: root/src/features/computer/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/computer/ui
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/computer/ui')
-rw-r--r--src/features/computer/ui/ComputerField.svelte410
-rw-r--r--src/features/computer/ui/ComputerSelect.svelte60
2 files changed, 236 insertions, 234 deletions
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>