diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 13:18:49 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 14:41:18 +0900 |
| commit | 60aa5dc48b6af502f88befd7d1517ab52cf6c60f (patch) | |
| tree | 4d4ea1eafed3ce0e56f67b23f1776f96f7f506bc /src/features/workspaces/store.test.ts | |
| parent | a59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff) | |
| download | dispatch-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/store.test.ts')
| -rw-r--r-- | src/features/workspaces/store.test.ts | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts new file mode 100644 index 0000000..4caac9f --- /dev/null +++ b/src/features/workspaces/store.test.ts @@ -0,0 +1,145 @@ +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "./adapter/http"; +import { createWorkspaceStore } from "./store.svelte"; + +function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +/** A fake `WorkspaceHttp` with stubbed star/unstar + a controllable list. */ +function fakeHttp(opts: { + list?: readonly WorkspaceEntry[]; + star?: (id: string) => Promise<WorkspaceResult<Workspace>>; + unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>; +}) { + return { + list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), + ensure: vi.fn(), + get: vi.fn(), + setTitle: vi.fn(), + setDefaultCwd: vi.fn(), + setDefaultComputer: vi.fn(), + star: + opts.star ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, starred: true }), + }), + ), + unstar: + opts.unstar ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, starred: false }), + }), + ), + delete: vi.fn(), + }; +} + +describe("createWorkspaceStore — setStarred", () => { + it("optimistically flips starred to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.star = 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]?.starred === true; + return { ok: true, value: entry({ id: "a", starred: true }) }; + }); + + await store.setStarred("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.star).toHaveBeenCalledWith("a"); + expect(store.list[0]?.starred).toBe(true); + }); + + it("calls unstar (DELETE) when starring false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setStarred("a", false); + + expect(http.unstar).toHaveBeenCalledWith("a"); + expect(http.star).not.toHaveBeenCalled(); + expect(store.list[0]?.starred).toBe(false); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.star = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setStarred("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + // Reverted to the prior value. + expect(store.list[0]?.starred).toBe(false); + }); + + it("does not set the store-wide load error on a star failure", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.star = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + await store.setStarred("a", true); + + expect(store.error).toBeNull(); + }); + + it("re-sorts so starred workspaces bubble to the top", async () => { + const http = fakeHttp({ + list: [ + entry({ id: "plain", starred: false, lastActivityAt: 9_000 }), + entry({ id: "star", starred: false, lastActivityAt: 1_000 }), + ], + }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Before: backend order (most-active first). + expect(store.list.map((w) => w.id)).toEqual(["plain", "star"]); + + await store.setStarred("star", true); + + // After: starred jumps above the more-recent unstarred workspace. + expect(store.list.map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("treats a missing id as not-starred and still calls through (create-on-miss)", async () => { + const http = fakeHttp({ list: [] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + const result = await store.setStarred("ghost", true); + + expect(result.ok).toBe(true); + expect(http.star).toHaveBeenCalledWith("ghost"); + // The list is unchanged (the workspace wasn't loaded); a refresh reconciles. + expect(store.list).toHaveLength(0); + }); +}); |
