From caf7060ec571039102acd7b4fd73008fdecbe8a9 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 20 Sep 2026 16:55:12 +0900 Subject: feat(workspaces): archive option + archived-workspaces view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the backend's archive contract (feature/archive-workspaces): - adapter/http: archive(id)/unarchive(id) (PUT/DELETE /workspaces/:id/archive); list(archived?) passes the ?archived= query - logic/view-model: pure applyArchived(list, id, archived) - store.svelte: setArchived(id, archived) (optimistic + revert-on-error + reconciling refresh); archivedList + refreshArchived() for the archived view - ui/TileActionBar: new Archive action (the right-click→select action bar) - ui/HomeHeader: new Archived button opens the archived view - ui/ArchivedWorkspaces (new): modal listing archived workspaces with Unarchive + View (navigates to /) - ui/WorkspacesHome: wire the Archive action + the Archived modal - .dispatch/wire.reference.md + backend-handoff.md: record the contract delta --- src/features/workspaces/adapter/http.test.ts | 63 +++++++++++ src/features/workspaces/adapter/http.ts | 42 +++++++- src/features/workspaces/logic/view-model.test.ts | 59 +++++++++- src/features/workspaces/logic/view-model.ts | 14 +++ src/features/workspaces/store.svelte.ts | 51 ++++++++- src/features/workspaces/store.test.ts | 105 +++++++++++++++++- .../workspaces/ui/ArchivedWorkspaces.svelte | 119 +++++++++++++++++++++ src/features/workspaces/ui/HomeHeader.svelte | 19 ++++ src/features/workspaces/ui/HomeHeader.test.ts | 1 + src/features/workspaces/ui/HomeTileGrid.test.ts | 1 + src/features/workspaces/ui/TileActionBar.svelte | 4 + src/features/workspaces/ui/TileActionBar.test.ts | 5 + .../workspaces/ui/WorkspaceDetails.test.ts | 1 + src/features/workspaces/ui/WorkspaceTile.test.ts | 1 + src/features/workspaces/ui/WorkspacesHome.svelte | 22 ++++ src/features/workspaces/ui/WorkspacesHome.test.ts | 1 + 16 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 src/features/workspaces/ui/ArchivedWorkspaces.svelte (limited to 'src/features') diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index 18d8939..2e34822 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -186,4 +186,67 @@ describe("createWorkspaceHttp", () => { const result = await http.unstar("a"); expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); }); + + it("list passes the archived filter as a query param", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list("true"); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces?archived=true`); + }); + + it("list omits the query param when no filter is given", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list(); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); + + it("archive PUTs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.archive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unarchive DELETEs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unarchive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("archive surfaces the backend error on a 409 (default workspace)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: 'The "default" workspace cannot be archived' } }]), + ); + const result = await http.archive("default"); + expect(result).toEqual({ ok: false, error: 'The "default" workspace cannot be archived' }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 5673881..de95a61 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -19,7 +19,7 @@ import type { * safe empty/null on failure (non-fatal — the UI falls back gracefully). * * Endpoints (transport-contract@0.16.0): - * - `GET /workspaces` → list + * - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure * - `GET /workspaces/:id` (404 → null) → get * - `PUT /workspaces/:id/title` → rename @@ -27,14 +27,19 @@ import type { * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority) * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar + * - `PUT /workspaces/:id/archive` (create-on-miss; 409 for "default") → archive (hide from home grid) + * - `DELETE /workspaces/:id/archive` (create-on-miss; 409 for "default") → unarchive * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; +/** Which workspaces `GET /workspaces` returns: active-only (default), archived-only, or all. */ +export type WorkspaceArchivedFilter = "true" | "false" | "all"; + export interface WorkspaceHttp { - list(): Promise; + list(archived?: WorkspaceArchivedFilter): Promise; ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; get(id: string): Promise; setTitle(id: string, title: string): Promise>; @@ -44,6 +49,10 @@ export interface WorkspaceHttp { star(id: string): Promise>; /** Unstar a workspace. Create-on-miss; no body. */ unstar(id: string): Promise>; + /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */ + archive(id: string): Promise>; + /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */ + unarchive(id: string): Promise>; delete(id: string): Promise>; } @@ -58,9 +67,10 @@ async function errText(res: Response): Promise { export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { return { - async list(): Promise { + async list(archived?: WorkspaceArchivedFilter): Promise { try { - const res = await fetchImpl(`${httpBase}/workspaces`); + const qs = archived !== undefined ? `?archived=${archived}` : ""; + const res = await fetchImpl(`${httpBase}/workspaces${qs}`); if (!res.ok) return []; const data = (await res.json()) as WorkspaceListResponse; return data.workspaces; @@ -171,6 +181,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async archive(id): Promise> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "PUT", + }); + 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 : "Archive failed" }; + } + }, + + async unarchive(id): Promise> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "DELETE", + }); + 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 : "Unarchive failed" }; + } + }, + async delete(id): Promise> { 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 d13f485..31366ba 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,13 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyStarred, pageTitle, relativeTime, sortWorkspaces, tileTone } from "./view-model"; +import { + applyArchived, + applyStarred, + pageTitle, + relativeTime, + sortWorkspaces, + tileTone, +} from "./view-model"; describe("tileTone", () => { const TONES = ["primary", "secondary", "accent", "info", "success", "warning"]; @@ -69,6 +76,7 @@ describe("pageTitle", () => { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -106,6 +114,7 @@ describe("sortWorkspaces", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt, conversationCount: 0, @@ -164,6 +173,7 @@ describe("applyStarred", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -208,3 +218,50 @@ describe("applyStarred", () => { expect(reverted[0]?.starred).toBe(false); }); }); + +describe("applyArchived", () => { + const entry = (id: string, archived: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's archived flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyArchived(list, "b", true); + expect(next.map((w) => [w.id, w.archived])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyArchived(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.archived).toBe(false); + expect(next[0]?.archived).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyArchived([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("can revert by re-applying the previous value (unarchive)", () => { + const list = [entry("a", false)]; + const optimistic = applyArchived(list, "a", true); + expect(optimistic[0]?.archived).toBe(true); + const reverted = applyArchived(optimistic, "a", false); + expect(reverted[0]?.archived).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index 6f5a635..cdf99c8 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -83,6 +83,20 @@ export function applyStarred( return workspaces.map((w) => (w.id === id ? { ...w, starred } : w)); } +/** + * Return a NEW list with the one workspace's `archived` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * archive/unarchive transformation shared by the apply + the error revert. A + * missing `id` leaves the list unchanged; a subsequent refresh reconciles. + */ +export function applyArchived( + workspaces: readonly T[], + id: string, + archived: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, archived } : w)); +} + /** * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 22d73b5..57e34b2 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,7 +1,7 @@ import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; -import { applyStarred, sortWorkspaces } from "./logic/view-model"; +import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model"; /** * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the @@ -16,10 +16,15 @@ export interface WorkspaceStore { * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. */ readonly list: readonly WorkspaceEntry[]; + /** Archived workspaces, for the archived view. Loaded via `refreshArchived()`. */ + readonly archivedList: readonly WorkspaceEntry[]; readonly loading: boolean; + readonly archivedLoading: boolean; readonly error: string | null; /** Refresh the list from the backend. */ refresh(): Promise; + /** Refresh the archived list (`GET /workspaces?archived=true`). */ + refreshArchived(): Promise; /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; /** Rename a workspace (display only; id unchanged). */ @@ -34,25 +39,41 @@ export interface WorkspaceStore { * reverts to the prior value. No full refresh on success (avoids flicker). */ setStarred(id: string, starred: boolean): Promise>; + /** + * Archive or unarchive a workspace. Optimistic: the local `archived` flag + * flips immediately (archiving drops the tile from the home grid); on error + * it reverts to the prior value. A successful archive/unarchive is followed + * by a `refresh()` so the active list reflects the backend (the archived + * workspace leaves/enters the default-filtered list). + */ + setArchived(id: string, archived: boolean): Promise>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { let list = $state([]); + let archivedList = $state([]); // Sorted view (starred first, then most-recent) — derived so it recomputes // only when the backing list changes, not on every read. let sorted = $derived(sortWorkspaces(list)); let loading = $state(false); + let archivedLoading = $state(false); let error = $state(null); return { get list(): readonly WorkspaceEntry[] { return sorted; }, + get archivedList(): readonly WorkspaceEntry[] { + return archivedList; + }, get loading(): boolean { return loading; }, + get archivedLoading(): boolean { + return archivedLoading; + }, get error(): string | null { return error; }, @@ -69,6 +90,18 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { } }, + async refreshArchived(): Promise { + archivedLoading = true; + error = null; + try { + archivedList = await http.list("true"); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load archived workspaces"; + } finally { + archivedLoading = false; + } + }, + async ensure(id, body): Promise> { const result = await http.ensure(id, body); if (result.ok) void this.refresh(); @@ -105,6 +138,22 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setArchived(id, archived): Promise> { + const prev = list.find((w) => w.id === id)?.archived ?? false; + // Optimistic: flip immediately so the tile leaves/enters the home grid. + list = applyArchived(list, id, archived); + const result = archived ? await http.archive(id) : await http.unarchive(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyArchived(list, id, prev); + return result; + } + // Reconcile with the backend's default (active-only) filter so the + // archived tile is dropped / the unarchived tile is restored. + void this.refresh(); + return result; + }, + async remove(id): Promise> { const result = await http.delete(id); if (result.ok) void this.refresh(); diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts index 4caac9f..a44d8a6 100644 --- a/src/features/workspaces/store.test.ts +++ b/src/features/workspaces/store.test.ts @@ -10,6 +10,7 @@ function entry(overrides: Partial = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, @@ -22,9 +23,14 @@ function fakeHttp(opts: { list?: readonly WorkspaceEntry[]; star?: (id: string) => Promise>; unstar?: (id: string) => Promise>; + archive?: (id: string) => Promise>; + unarchive?: (id: string) => Promise>; }) { return { - list: vi.fn(async (): Promise => opts.list ?? []), + list: vi.fn( + async (_archived?: "true" | "false" | "all"): Promise => + opts.list ?? [], + ), ensure: vi.fn(), get: vi.fn(), setTitle: vi.fn(), @@ -46,6 +52,22 @@ function fakeHttp(opts: { value: entry({ id, starred: false }), }), ), + archive: + opts.archive ?? + vi.fn( + async (id: string): Promise> => ({ + ok: true, + value: entry({ id, archived: true }), + }), + ), + unarchive: + opts.unarchive ?? + vi.fn( + async (id: string): Promise> => ({ + ok: true, + value: entry({ id, archived: false }), + }), + ), delete: vi.fn(), }; } @@ -143,3 +165,84 @@ describe("createWorkspaceStore — setStarred", () => { expect(store.list).toHaveLength(0); }); }); + +describe("createWorkspaceStore — setArchived", () => { + it("optimistically flips archived to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.archive = vi.fn(async (_id: string): Promise> => { + // While the request is in flight, the store already shows the new state. + observedDuringCall = store.list[0]?.archived === true; + return { ok: true, value: entry({ id: "a", archived: true }) }; + }); + + await store.setArchived("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.archive).toHaveBeenCalledWith("a"); + }); + + it("calls unarchive (DELETE) when archiving false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setArchived("a", false); + + expect(http.unarchive).toHaveBeenCalledWith("a"); + expect(http.archive).not.toHaveBeenCalled(); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.archive = vi.fn( + async (): Promise> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setArchived("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + expect(store.list[0]?.archived).toBe(false); + }); + + it("refreshes the active list after a successful archive", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + const listCallsBefore = http.list.mock.calls.length; + + await store.setArchived("a", true); + + // setArchived triggers a refresh() to reconcile the active-only list. + expect(http.list.mock.calls.length).toBeGreaterThan(listCallsBefore); + }); +}); + +describe("createWorkspaceStore — refreshArchived", () => { + it("loads the archived list via list('true') without touching the main list", async () => { + const http = fakeHttp({ list: [entry({ id: "active", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Point the archived call at a different payload. + http.list = vi.fn( + async (archived?: "true" | "false" | "all"): Promise => + archived === "true" + ? [entry({ id: "done", archived: true })] + : [entry({ id: "active", archived: false })], + ); + + await store.refreshArchived(); + + expect(http.list).toHaveBeenCalledWith("true"); + expect(store.archivedList.map((w) => w.id)).toEqual(["done"]); + // The main (active) list is untouched. + expect(store.list.map((w) => w.id)).toEqual(["active"]); + }); +}); diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte new file mode 100644 index 0000000..584fd68 --- /dev/null +++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte @@ -0,0 +1,119 @@ + + + diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte index fb0be0a..9d26306 100644 --- a/src/features/workspaces/ui/HomeHeader.svelte +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -11,6 +11,7 @@ onCreate, onOpenPlugins, onSort, + onOpenArchived, }: { /** Tall geometry: the controls collapse to compact icon buttons and the * new-workspace popover becomes a bottom sheet. */ @@ -24,6 +25,8 @@ onCreate: (slug: string) => Promise; onOpenPlugins: () => void; onSort: () => void; + /** Open the archived-workspaces view. */ + onOpenArchived: () => void; } = $props(); // ── New workspace popover (today's inline form logic, preserved) ────────── @@ -119,6 +122,22 @@ {/if} + + + diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts index b4ef6c4..40b060f 100644 --- a/src/features/workspaces/ui/HomeHeader.test.ts +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -8,6 +8,7 @@ function props() { onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })), onOpenPlugins: vi.fn(), onSort: vi.fn(), + onOpenArchived: vi.fn(), }; } diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts index d70c673..a8365d5 100644 --- a/src/features/workspaces/ui/HomeTileGrid.test.ts +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -14,6 +14,7 @@ function ws(overrides: Partial = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 1, diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte index 0d8bef5..ffb8f1d 100644 --- a/src/features/workspaces/ui/TileActionBar.svelte +++ b/src/features/workspaces/ui/TileActionBar.svelte @@ -11,6 +11,7 @@ onToggleStar, onDetails, onPlugins, + onArchive, onDelete, onClose, }: { @@ -24,6 +25,8 @@ /** The details flyout (default cwd + default computer). */ onDetails: () => void; onPlugins: () => void; + /** Archive the workspace (hide from the home grid; reversible). */ + onArchive: () => void; onDelete: () => void; onClose: () => void; } = $props(); @@ -53,6 +56,7 @@ {#if withPlugins} {/if} +