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 /src/features/workspaces/ui | |
| 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
Diffstat (limited to 'src/features/workspaces/ui')
| -rw-r--r-- | src/features/workspaces/ui/ArchivedWorkspaces.svelte | 119 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeHeader.svelte | 19 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeHeader.test.ts | 1 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.test.ts | 1 | ||||
| -rw-r--r-- | src/features/workspaces/ui/TileActionBar.svelte | 4 | ||||
| -rw-r--r-- | src/features/workspaces/ui/TileActionBar.test.ts | 5 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceDetails.test.ts | 1 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceTile.test.ts | 1 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 22 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.test.ts | 1 |
10 files changed, 174 insertions, 0 deletions
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, |
