summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/workspaces')
-rw-r--r--src/features/workspaces/adapter/http.ts23
-rw-r--r--src/features/workspaces/logic/view-model.test.ts1
-rw-r--r--src/features/workspaces/store.svelte.ts8
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte40
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts23
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte10
6 files changed, 96 insertions, 9 deletions
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
index 97fc2e2..6ebfee1 100644
--- a/src/features/workspaces/adapter/http.ts
+++ b/src/features/workspaces/adapter/http.ts
@@ -1,6 +1,7 @@
import type {
DeleteWorkspaceResponse,
EnsureWorkspaceRequest,
+ SetWorkspaceDefaultComputerRequest,
SetWorkspaceDefaultCwdRequest,
SetWorkspaceTitleRequest,
Workspace,
@@ -23,6 +24,7 @@ import type {
* - `GET /workspaces/:id` (404 → null) → get
* - `PUT /workspaces/:id/title` → rename
* - `PUT /workspaces/:id/default-cwd` → set/clear default cwd
+ * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2)
* - `DELETE /workspaces/:id` (409 for "default") → delete
*/
export type WorkspaceResult<T> =
@@ -35,6 +37,7 @@ export interface WorkspaceHttp {
get(id: string): Promise<Workspace | null>;
setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
@@ -118,6 +121,26 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch):
}
},
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest),
+ },
+ );
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set default computer failed",
+ };
+ }
+ },
+
async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
try {
const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
index 52f7025..a4ed19c 100644
--- a/src/features/workspaces/logic/view-model.test.ts
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -36,6 +36,7 @@ describe("pageTitle", () => {
id,
title,
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
conversationCount: 0,
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
index 0f662dc..0cf4ce2 100644
--- a/src/features/workspaces/store.svelte.ts
+++ b/src/features/workspaces/store.svelte.ts
@@ -21,6 +21,8 @@ export interface WorkspaceStore {
rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
/** Set/clear a workspace's default cwd. */
setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
/** Delete a workspace (closes its conversations, reassigns to "default"). */
remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
@@ -71,6 +73,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
return result;
},
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultComputer(id, computerId);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+
async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
const result = await http.delete(id);
if (result.ok) void this.refresh();
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 6348bb9..4f846bf 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -1,18 +1,22 @@
<script lang="ts">
- import type { WorkspaceEntry } from "@dispatch/wire";
+ import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
import { untrack } from "svelte";
import type { WorkspaceStore } from "../store.svelte";
import { relativeTime } from "../logic/view-model";
import { workspacePath } from "../logic/route";
+ import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
let {
ws,
store,
onNavigate,
+ computers,
}: {
ws: WorkspaceEntry;
store: WorkspaceStore;
onNavigate: (path: string) => void;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
} = $props();
// ── Title: double-click to rename inline ──────────────────────────────────
@@ -64,6 +68,21 @@
if (!result.ok) cwdError = result.error;
}
+ // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
+ let savingComputer = $state(false);
+ let computerError = $state<string | null>(null);
+
+ async function saveComputer(computerId: string | null): Promise<void> {
+ if (savingComputer) return;
+ // No-op when unchanged (the select only fires on a real change, but guard).
+ if (computerId === (ws.defaultComputerId ?? null)) return;
+ savingComputer = true;
+ computerError = null;
+ const result = await store.setDefaultComputer(ws.id, computerId);
+ savingComputer = false;
+ if (!result.ok) computerError = result.error;
+ }
+
// ── Delete ─────────────────────────────────────────────────────────────────
let deleting = $state(false);
@@ -159,6 +178,19 @@
</button>
</div>
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
+ <ComputerSelect
+ value={ws.defaultComputerId}
+ {computers}
+ disabled={savingComputer}
+ onSelect={saveComputer}
+ />
+ {#if savingComputer}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
<div class="flex justify-start">
<a
class="btn"
@@ -177,4 +209,10 @@
{:else if !cwdDirty && !ws.defaultCwd}
<p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
{/if}
+
+ {#if computerError}
+ <p class="text-xs text-error">{computerError}</p>
+ {:else if !ws.defaultComputerId}
+ <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
+ {/if}
</li>
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index 385856d..48b12b3 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -11,6 +11,7 @@ function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
id: "my-ws",
title: "My Workspace",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 1,
lastActivityAt: 2,
conversationCount: 3,
@@ -33,6 +34,12 @@ function fakeStore() {
value: fakeEntry({ id, defaultCwd }),
}),
),
+ setDefaultComputer: vi.fn(
+ async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultComputerId: computerId }),
+ }),
+ ),
remove: vi.fn(
async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
ok: true,
@@ -47,7 +54,7 @@ describe("WorkspaceCard", () => {
const store = fakeStore() as unknown as WorkspaceStore;
const onNavigate = vi.fn();
render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate },
+ props: { ws: fakeEntry(), store, onNavigate, computers: [] },
});
expect(screen.getByText("My Workspace")).toBeInTheDocument();
expect(screen.getByText("/my-ws")).toBeInTheDocument();
@@ -57,7 +64,9 @@ describe("WorkspaceCard", () => {
it("double-clicking the title reveals an edit input", async () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate: vi.fn() } });
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
await user.dblClick(screen.getByText("My Workspace"));
expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
@@ -66,7 +75,9 @@ describe("WorkspaceCard", () => {
it("renames via the store on Enter", async () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate: vi.fn() } });
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
await user.dblClick(screen.getByText("My Workspace"));
const input = screen.getByLabelText("Workspace title");
@@ -80,7 +91,7 @@ describe("WorkspaceCard", () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn() },
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
});
const input = screen.getByLabelText("Default working directory");
@@ -99,7 +110,7 @@ describe("WorkspaceCard", () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn() },
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
});
const input = screen.getByLabelText("Default working directory");
@@ -113,7 +124,7 @@ describe("WorkspaceCard", () => {
const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
const onNavigate = vi.fn();
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate } });
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
await user.click(screen.getByRole("link", { name: "Open" }));
expect(onNavigate).toHaveBeenCalledWith("/my-ws");
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 5894f0a..bdfd023 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,10 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
+ import type { ComputerEntry } from "@dispatch/wire";
import type { WorkspaceStore } from "../store.svelte";
import { isValidSlug, workspacePath } from "../logic/route";
import WorkspaceCard from "./WorkspaceCard.svelte";
- let { store, onNavigate }: { store: WorkspaceStore; onNavigate: (path: string) => void } = $props();
+ let {
+ store,
+ onNavigate,
+ computers,
+ }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } =
+ $props();
onMount(() => {
void store.refresh();
@@ -78,7 +84,7 @@
{:else}
<ul class="flex flex-col gap-2">
{#each store.list as ws (ws.id)}
- <WorkspaceCard {ws} {store} {onNavigate} />
+ <WorkspaceCard {ws} {store} {onNavigate} {computers} />
{/each}
</ul>
{/if}