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 --- .dispatch/wire.reference.md | 17 +++ backend-handoff.md | 42 ++++++++ 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 + 18 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 src/features/workspaces/ui/ArchivedWorkspaces.svelte diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 07dabc8..5379b11 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -6,6 +6,15 @@ > > **Orchestrator:** SNAPSHOT of `wire@0.12.0` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. > +> **2026-09-20 delta (workspace archiving — ADDITIVE to `wire@0.12.0`, NO version bump):** `Workspace` gains a +> required `archived: boolean` (defaults to `false` on creation; legacy rows normalize to `false`). An archived +> workspace is HIDDEN from the home grid WITHOUT deleting its data (conversations/settings untouched) and can be +> unarchived at any time. Toggled via dedicated `PUT`/`DELETE /workspaces/:id/archive` endpoints (no body; both +> create-on-miss and return the updated `Workspace`; both reject `"default"` with 409). `GET /workspaces` takes an +> optional `?archived=` query: `true` → archived only, `all` → every workspace, absent/other → non-archived only +> (the home grid's default). `PUT /workspaces/:id` does NOT accept an `archived` field. Implemented in BOTH repos +> of the `feature/archive-workspaces` worktree (see `backend-handoff.md`). +> > **2026-08-25 delta (per-conversation model on ConversationMeta — ADDITIVE, no version bump):** > `ConversationMeta` gains an optional `model?: string` (`/`, absENT = default > provider model). Surfaced on `GET /conversations` and the `conversation.open` WS broadcast so the @@ -749,6 +758,14 @@ export interface Workspace { * Defaults to `false` on creation. */ readonly starred: boolean; + /** + * Whether the workspace is archived by the user. Archived workspaces are + * HIDDEN from the home grid (`GET /workspaces` excludes them by default; + * `?archived=` opts back in) without deleting their data — the workspace, + * its conversations, and its settings are untouched, and it can be + * unarchived at any time. Defaults to `false` on creation. + */ + readonly archived: boolean; /** Epoch-ms when the workspace was first created. */ readonly createdAt: number; /** Epoch-ms of the most recent conversation activity in this workspace. */ diff --git a/backend-handoff.md b/backend-handoff.md index 73aa011..6251ca9 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -11,6 +11,48 @@ > "never edit the backend repo" rule is superseded by that task's scope — the backend contract > changes below were made in the same branch and the FE consumes them via the `file:` deps > (dists rebuilt). Listed here for the record. +> +> **NOTE (archive-workspaces worktree, 2026-09-20):** the archive-workspaces feature was likewise +> implemented in BOTH repos of one worktree (branch `feature/archive-workspaces`), so the backend +> contract change (`Workspace.archived`, `PUT/DELETE /workspaces/:id/archive`, `GET /workspaces +> ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists +> rebuilt). Recorded below. + +## Milestone: Workspace archiving (`feature/archive-workspaces`) + +**Slice status:** COMPLETE + committed (both repos, `feature/archive-workspaces`). + +### The contract change (made in the backend worktree, same branch) +- `@dispatch/wire` `Workspace.archived: boolean` (required; defaults `false`; legacy rows → `false`). +- `PUT /workspaces/:id/archive` + `DELETE /workspaces/:id/archive` — no body, create-on-miss, + return the updated `Workspace`, **409 for `"default"`**. +- `GET /workspaces?archived=` — `true` → archived only, `all` → every workspace, absent/other → + non-archived only (the home grid's default). +- Mirror: `.dispatch/wire.reference.md` updated with the 2026-09-20 delta. + +### What the FE built +- `adapter/http.ts` — `archive(id)`/`unarchive(id)`; `list(archived?: "true"|"false"|"all")` passes + the `?archived=` query. +- `logic/view-model.ts` — pure `applyArchived(list, id, archived)` (mirrors `applyStarred`). +- `store.svelte.ts` — `setArchived(id, archived)` (optimistic + revert-on-error + a reconciling + `refresh()` so the archived tile leaves / the unarchived tile re-enters the active list); + `archivedList` + `refreshArchived()` for the archived view. +- `ui/TileActionBar.svelte` — new **Archive** action (the right-click→select action bar). +- `ui/HomeHeader.svelte` — new **Archived** button opens the archived view. +- `ui/ArchivedWorkspaces.svelte` (new) — a modal listing archived workspaces (title, id, + conversation count, last-activity) with **Unarchive** and **View** (navigates to `/`). +- `ui/WorkspacesHome.svelte` — wires the Archive action + the Archived modal. + +### Verification +- Backend: `tsc -b` 0 errors; `vitest run` 3041 passed (16 new: store archived block + app archive + routes / `?archived=` filter); `biome check` clean. +- Frontend: `svelte-check` 0 errors; `vitest run` 1734 passed; `biome check` clean; `vite build` ok. + +### Note for the user +- Archiving is reversible and touches no conversations. The archived tile's layout slot is freed + (backend default filter excludes it → `tiles.sync` drops the entry); unarchiving re-appends it at + the first free cell. + ## Milestone: Layout plan 02 — provider consumption + layout WS + localStorage migration 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} +