summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/wire.reference.md17
-rw-r--r--backend-handoff.md42
-rw-r--r--src/features/workspaces/adapter/http.test.ts63
-rw-r--r--src/features/workspaces/adapter/http.ts42
-rw-r--r--src/features/workspaces/logic/view-model.test.ts59
-rw-r--r--src/features/workspaces/logic/view-model.ts14
-rw-r--r--src/features/workspaces/store.svelte.ts51
-rw-r--r--src/features/workspaces/store.test.ts105
-rw-r--r--src/features/workspaces/ui/ArchivedWorkspaces.svelte119
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte19
-rw-r--r--src/features/workspaces/ui/HomeHeader.test.ts1
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.test.ts1
-rw-r--r--src/features/workspaces/ui/TileActionBar.svelte4
-rw-r--r--src/features/workspaces/ui/TileActionBar.test.ts5
-rw-r--r--src/features/workspaces/ui/WorkspaceDetails.test.ts1
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.test.ts1
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte22
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts1
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,