diff options
| author | Adam Malczewski <[email protected]> | 2026-09-20 16:55:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-20 16:55:12 +0900 |
| commit | caf7060ec571039102acd7b4fd73008fdecbe8a9 (patch) | |
| tree | 7eac6c55556d20021de4a6e19f63d8efaa21ce84 | |
| parent | 72663945ef0a0053fbfd83b168e90f12169a5c3a (diff) | |
| download | dispatch-web-caf7060ec571039102acd7b4fd73008fdecbe8a9.tar.gz dispatch-web-caf7060ec571039102acd7b4fd73008fdecbe8a9.zip | |
feat(workspaces): archive option + archived-workspaces view
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 /<id>)
- ui/WorkspacesHome: wire the Archive action + the Archived modal
- .dispatch/wire.reference.md + backend-handoff.md: record the contract delta
18 files changed, 560 insertions, 7 deletions
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 `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. > +> **2026-09-20 delta (workspace archiving — ADDITIVE to `[email protected]`, 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` (`<credential>/<model>`, 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 `/<id>`). +- `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<typeof vi.fn>).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<typeof vi.fn>).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 ([email protected]): - * - `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<T> = | { 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<readonly WorkspaceEntry[]>; + list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>; ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; get(id: string): Promise<Workspace | null>; setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; @@ -44,6 +49,10 @@ export interface WorkspaceHttp { star(id: string): Promise<WorkspaceResult<Workspace>>; /** Unstar a workspace. Create-on-miss; no body. */ unstar(id: string): Promise<WorkspaceResult<Workspace>>; + /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */ + archive(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */ + unarchive(id: string): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -58,9 +67,10 @@ async function errText(res: Response): Promise<string> { export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { return { - async list(): Promise<readonly WorkspaceEntry[]> { + async list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]> { 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<WorkspaceResult<Workspace>> { + 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<WorkspaceResult<Workspace>> { + 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<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 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 @@ -84,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>( } /** + * 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<T extends WorkspaceEntry>( + 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 * (a workspace just created) read as "now". 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<void>; + /** Refresh the archived list (`GET /workspaces?archived=true`). */ + refreshArchived(): Promise<void>; /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; /** 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<WorkspaceResult<Workspace>>; + /** + * 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<WorkspaceResult<Workspace>>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { let list = $state<readonly WorkspaceEntry[]>([]); + let archivedList = $state<readonly WorkspaceEntry[]>([]); // 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<string | null>(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<void> { + 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<WorkspaceResult<Workspace>> { 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<WorkspaceResult<Workspace>> { + 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<WorkspaceResult<{ closedCount: number }>> { 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> = {}): 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<WorkspaceResult<Workspace>>; unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>; + archive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>; }) { return { - list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), + list: vi.fn( + async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + 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<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: true }), + }), + ), + unarchive: + opts.unarchive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + 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<WorkspaceResult<Workspace>> => { + // 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<WorkspaceResult<Workspace>> => ({ 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<readonly WorkspaceEntry[]> => + 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 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + + let { + store, + onOpen, + onClose, + }: { + store: WorkspaceStore; + /** Open (view) a workspace — navigates to `/<id>`. */ + onOpen: (id: string) => void; + onClose: () => void; + } = $props(); + + // Load the archived list when the modal mounts. + $effect(() => { + void store.refreshArchived(); + }); + + let busyId = $state<string | null>(null); + let actionError = $state<string | null>(null); + + async function unarchive(ws: WorkspaceEntry): Promise<void> { + if (busyId !== null) return; + busyId = ws.id; + actionError = null; + const result = await store.setArchived(ws.id, false); + busyId = null; + if (!result.ok) { + actionError = result.error; + return; + } + // Refresh the archived list so the unarchived workspace leaves it. + void store.refreshArchived(); + } + + function open(ws: WorkspaceEntry): void { + onOpen(ws.id); + onClose(); + } +</script> + +<div class="modal modal-open" role="dialog" aria-label="Archived workspaces"> + <div class="modal-box max-w-lg"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">Archived workspaces</h2> + <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close archived workspaces" + onclick={onClose}>✕</button + > + </div> + + {#if actionError} + <p class="mt-3 text-xs text-error">{actionError}</p> + {/if} + + <div class="mt-4"> + {#if store.archivedLoading && store.archivedList.length === 0} + <div class="flex justify-center py-8"> + <span class="loading loading-spinner loading-md opacity-60"></span> + </div> + {:else if store.archivedList.length === 0} + <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.archivedList as ws (ws.id)} + <li + class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2" + > + <div class="min-w-0 flex-1"> + <p class="truncate font-semibold">{ws.title}</p> + <p class="font-mono text-xs opacity-50"> + /{ws.id} · {ws.conversationCount} conv · {relativeTime( + ws.lastActivityAt, + Date.now(), + )} ago + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => open(ws)} + aria-label={`Open ${ws.title}`}>View</button + > + <button + type="button" + class="btn btn-outline btn-xs" + disabled={busyId === ws.id} + onclick={() => void unarchive(ws)} + aria-label={`Unarchive ${ws.title}`} + > + {#if busyId === ws.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Unarchive + {/if} + </button> + </div> + </li> + {/each} + </ul> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close archived workspaces backdrop" + onclick={onClose}></button> +</div> 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<CreateResult>; 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 @@ </ul> </div> {/if} + + <!-- Archived workspaces --> + <button + type="button" + class="btn btn-ghost btn-sm" + class:btn-circle={compact} + aria-label="Archived workspaces" + title="View archived workspaces" + onclick={onOpenArchived} + > + {#if compact} + <span aria-hidden="true">🗄</span> + {:else} + Archived + {/if} + </button> </div> </header> 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> = {}): 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} <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button> {/if} + <button type="button" class="btn btn-ghost btn-sm" onclick={onArchive}>Archive</button> <button type="button" class="btn btn-ghost btn-sm text-error" diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts index 4b0377e..c00de19 100644 --- a/src/features/workspaces/ui/TileActionBar.test.ts +++ b/src/features/workspaces/ui/TileActionBar.test.ts @@ -11,6 +11,7 @@ function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, @@ -26,6 +27,7 @@ function handlers() { onToggleStar: vi.fn(), onDetails: vi.fn(), onPlugins: vi.fn(), + onArchive: vi.fn(), onDelete: vi.fn(), onClose: vi.fn(), }; @@ -40,6 +42,7 @@ describe("TileActionBar", () => { expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument(); }); @@ -74,6 +77,7 @@ describe("TileActionBar", () => { await user.click(screen.getByRole("button", { name: "Resize" })); await user.click(screen.getByRole("button", { name: "Star" })); await user.click(screen.getByRole("button", { name: "Details" })); + await user.click(screen.getByRole("button", { name: "Archive" })); await user.click(screen.getByRole("button", { name: "Delete" })); await user.click(screen.getByRole("button", { name: "Close actions" })); @@ -82,6 +86,7 @@ describe("TileActionBar", () => { expect(h.onResize).toHaveBeenCalledTimes(1); expect(h.onToggleStar).toHaveBeenCalledTimes(1); expect(h.onDetails).toHaveBeenCalledTimes(1); + expect(h.onArchive).toHaveBeenCalledTimes(1); expect(h.onDelete).toHaveBeenCalledTimes(1); expect(h.onClose).toHaveBeenCalledTimes(1); }); diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts index c903ac0..9138742 100644 --- a/src/features/workspaces/ui/WorkspaceDetails.test.ts +++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts @@ -13,6 +13,7 @@ function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts index b76f709..420791a 100644 --- a/src/features/workspaces/ui/WorkspaceTile.test.ts +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -14,6 +14,7 @@ function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: Date.now() - 90_000, // "1m" relative time conversationCount: 3, diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 798d1bb..b330e1d 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -22,6 +22,7 @@ import TileActionBar from "./TileActionBar.svelte"; import ResizePicker from "./ResizePicker.svelte"; import WorkspaceDetails from "./WorkspaceDetails.svelte"; + import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte"; let { store, @@ -178,6 +179,9 @@ // loads/unloads code (strict layering). let managerOpen = $state(false); + // ── Archived workspaces modal ────────────────────────────────────────────── + let archivedOpen = $state(false); + // ── Actions (the action bar) ─────────────────────────────────────────────── function openWorkspace(id: string): void { deselect(); @@ -203,6 +207,14 @@ await store.setStarred(selected.id, !selected.starred); } + async function archiveSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + // Reversible without a confirm dialog — the workspace is hidden, not deleted. + const result = await store.setArchived(ws.id, true); + if (result.ok) deselect(); + } + // ── Create (header popover): create on the backend — do NOT navigate — // place the new tile in the first empty slot, and go straight into rename. async function createWorkspace( @@ -245,6 +257,7 @@ onCreate={createWorkspace} onOpenPlugins={() => (managerOpen = true)} onSort={() => void sortByRecent()} + onOpenArchived={() => (archivedOpen = true)} /> <div bind:this={regionEl} class="min-h-0 w-full flex-1"> @@ -294,6 +307,7 @@ onToggleStar={() => void toggleStar()} onDetails={() => (detailsForId = selected.id)} onPlugins={() => (pluginsModalId = selected.id)} + onArchive={() => void archiveSelected()} onDelete={() => void deleteSelected()} onClose={deselect} /> @@ -328,3 +342,11 @@ {#if managerOpen && plugins} <PluginManager {plugins} onClose={() => (managerOpen = false)} /> {/if} + +{#if archivedOpen} + <ArchivedWorkspaces + {store} + onOpen={(id) => openWorkspace(id)} + onClose={() => (archivedOpen = false)} + /> +{/if} diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts index 9e8411a..6316930 100644 --- a/src/features/workspaces/ui/WorkspacesHome.test.ts +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -15,6 +15,7 @@ function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 1, |
