summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/store.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/store.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/store.test.ts')
-rw-r--r--src/features/workspaces/store.test.ts145
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);
+ });
+});