summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/WorkspaceCard.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 13:18:49 +0900
committerAdam Malczewski <[email protected]>2026-06-28 14:41:18 +0900
commit60aa5dc48b6af502f88befd7d1517ab52cf6c60f (patch)
tree4d4ea1eafed3ce0e56f67b23f1776f96f7f506bc /src/features/workspaces/ui/WorkspaceCard.test.ts
parenta59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff)
downloaddispatch-web-60aa5dc48b6af502f88befd7d1517ab52cf6c60f.tar.gz
dispatch-web-60aa5dc48b6af502f88befd7d1517ab52cf6c60f.zip
feat(workspaces): star toggle for concurrency priority
Backend (feature/workspace-star) shipped Workspace.starred: boolean (additive to [email protected], no version bump) + PUT/DELETE /workspaces/:id/star endpoints (no body; create-on-miss; return the updated Workspace). A starred workspace's agents jump ahead of non-starred ones in the concurrency limiter queue (oldest-agent-first within each group); takes effect immediately for already-queued agents. FE consumed: - adapter/http.ts: star(id)/unstar(id) -> WorkspaceResult<Workspace> (PUT/DELETE /workspaces/:id/star, no body). - logic/view-model.ts: pure sortWorkspaces (starred-first, then lastActivityAt desc, stable) + pure applyStarred (the optimistic apply/revert transform). - store.svelte.ts: setStarred(id, starred) — optimistic flip with error revert; the list is now a $derived sorted view (starred bubble to top reactively); no full refresh on success (avoids flicker). - ui/WorkspaceCard.svelte: star toggle button (filled gold when starred, outline when not; spinner in flight; aria-pressed/aria-label; tooltip notes concurrency priority). - Re-mirrored .dispatch/wire.reference.md (starred + delta note). - GLOSSARY.md: 'starred' term. Tests (+27): http star/unstar (5), view-model sort+applyStarred (12), store optimistic+revert+re-sort (6), WorkspaceCard star button (4). Verification: typecheck 0/0, 1045 tests green, biome clean, build OK. backend-handoff.md updated (workspace-star slice, no open backend asks).
Diffstat (limited to 'src/features/workspaces/ui/WorkspaceCard.test.ts')
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts108
1 files changed, 108 insertions, 0 deletions
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index 0d03b8e..f3ed1e7 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -12,6 +12,7 @@ function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
title: "My Workspace",
defaultCwd: null,
defaultComputerId: null,
+ starred: false,
createdAt: 1,
lastActivityAt: 2,
conversationCount: 3,
@@ -40,6 +41,12 @@ function fakeStore() {
value: fakeEntry({ id, defaultComputerId: computerId }),
}),
),
+ setStarred: vi.fn(
+ async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, starred }),
+ }),
+ ),
remove: vi.fn(
async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
ok: true,
@@ -135,4 +142,105 @@ describe("WorkspaceCard", () => {
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("/my-ws");
});
+
+ it("renders an outline star button for an unstarred workspace", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+ const star = screen.getByRole("button", { name: "Star workspace" });
+ expect(star).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("renders a filled star button for a starred workspace", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
+ });
+ const star = screen.getByRole("button", { name: "Unstar workspace" });
+ expect(star).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("toggles the star via the store on click", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Star workspace" }));
+ expect(store.setStarred).toHaveBeenCalledWith("my-ws", true);
+ });
+
+ it("clicking a starred workspace's star calls setStarred(id, false)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Unstar workspace" }));
+ expect(store.setStarred).toHaveBeenCalledWith("my-ws", false);
+ });
+
+ it("renders no star error on a successful toggle", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Star workspace" }));
+ expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument();
+ });
+
+ it("shows an inline error when setStarred fails (result.ok false)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ // The store reverts the optimistic flip on failure, so the entry's
+ // `starred` stays false — fake the revert by returning ok:false unchanged.
+ store.setStarred = vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
+ );
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Star workspace" }));
+
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("re-enables the star button after a failure (savingStar resets)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ store.setStarred = vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
+ );
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const star = screen.getByRole("button", { name: "Star workspace" });
+ await user.click(star);
+ // After the failed toggle, the button is NOT disabled (savingStar reset).
+ expect(star).not.toBeDisabled();
+ });
+
+ it("re-enables the star button even when setStarred throws", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => {
+ throw new Error("network");
+ });
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const star = screen.getByRole("button", { name: "Star workspace" });
+ await user.click(star);
+ // savingStar must reset via try/finally even on a throw.
+ expect(star).not.toBeDisabled();
+ expect(screen.getByText("network")).toBeInTheDocument();
+ });
});