summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-20 16:55:12 +0900
committerAdam Malczewski <[email protected]>2026-09-20 16:55:12 +0900
commitcaf7060ec571039102acd7b4fd73008fdecbe8a9 (patch)
tree7eac6c55556d20021de4a6e19f63d8efaa21ce84 /src/features/workspaces/ui
parent72663945ef0a0053fbfd83b168e90f12169a5c3a (diff)
downloaddispatch-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.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
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,