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 | |
| 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).
| -rw-r--r-- | .dispatch/wire.reference.md | 14 | ||||
| -rw-r--r-- | GLOSSARY.md | 1 | ||||
| -rw-r--r-- | backend-handoff.md | 38 | ||||
| -rw-r--r-- | src/features/workspaces/adapter/http.test.ts | 56 | ||||
| -rw-r--r-- | src/features/workspaces/adapter/http.ts | 30 | ||||
| -rw-r--r-- | src/features/workspaces/index.ts | 2 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 113 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 30 | ||||
| -rw-r--r-- | src/features/workspaces/store.svelte.ts | 30 | ||||
| -rw-r--r-- | src/features/workspaces/store.test.ts | 145 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 45 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 108 |
12 files changed, 606 insertions, 6 deletions
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 888c160..b430a45 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -6,6 +6,13 @@ > > **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. > +> **2026-06-27 delta (workspace starring — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a +> required `starred: boolean` (defaults to `false` on creation). A starred workspace's agents receive +> PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces +> (oldest-agent-first within each group). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints +> (no body; both create-on-miss and return the updated `Workspace`). `PUT /workspaces/:id` does NOT accept a +> `starred` field. See `backend-handoff.md`. +> > **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus` > widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a > per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows @@ -708,6 +715,13 @@ export interface Workspace { * (per-conv `computerId` → this → `null`/local). */ readonly defaultComputerId: string | null; + /** + * Whether the workspace is starred by the user. Starred workspaces receive + * PRIORITY in the concurrency limiter queue — their agents jump ahead + * of agents from non-starred workspaces (oldest-agent-first within each group). + * Defaults to `false` on creation. + */ + readonly starred: boolean; /** Epoch-ms when the workspace was first created. */ readonly createdAt: number; /** Epoch-ms of the most recent conversation activity in this workspace. */ diff --git a/GLOSSARY.md b/GLOSSARY.md index 14b81d1..4ca083c 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -50,3 +50,4 @@ | **unload** | Drop the oldest COMMITTED chunks from the in-memory transcript (and DOM) past the **chat limit** — in BULK (`ceil(limit/4)` per pass, deferred while the reader is scrolled up), never one-per-delta (old Dispatch's scroll-jump bug). Purely local: the IndexedDB cache and the server keep everything; `TranscriptState.hiddenBeforeSeq` is the watermark. Distinct from the conversation-cache's cross-conversation **eviction**. | evict (reserved for the cross-conversation cache), prune, drop | | **show earlier** | The affordance at the top of a transcript with unloaded history ("Show earlier messages"): pages one unload-unit back in — local cache first, then the server (CR-5 `?beforeSeq=&limit=`) when the cache doesn't reach far enough back — preserving the reader's scroll position. Offered whenever the loaded window starts above seq 1 (the [email protected] 1-based gap-free seq contract). | load more, pagination | | **workspaces** | A URL-driven grouping of conversations that owns a **default cwd**. The root path `/` lists workspaces; `/<id>` opens one (visiting a nonexistent id creates it — create-on-miss). Each conversation belongs to exactly one workspace; the always-present `"default"` workspace is the fallback for unassigned/legacy conversations. Tabs are scoped per workspace (filtered client-side). Backend-owned (`[email protected]` `Workspace`); a conversation's cwd inherits `workspace.defaultCwd` when its own is unset. Distinct from the per-conversation cwd+LSP module (`features/cwd-lsp`, formerly `features/workspace`). | project, space | +| **starred** | A workspace flag (`Workspace.starred: boolean`, defaults `false`) that grants its agents PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces, oldest-agent-first within each group. Takes effect immediately for already-queued agents (backend-owned). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss; `PUT /workspaces/:id` does NOT accept `starred`). The FE sorts starred workspaces to the top of the home list (the display echo of their concurrency priority) and toggles optimistically with error revert. | favorited, pinned (and do NOT call it "priority" — priority is the EFFECT, starred is the FLAG) | diff --git a/backend-handoff.md b/backend-handoff.md index 5b41741..3f4642b 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,10 +5,15 @@ > **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. +_Last updated: 2026-06-27 (workspace starring — 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`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`, +`store.setStarred` (optimistic + revert, `$derived`-sorted list), `WorkspaceCard` star toggle (filled gold ★ / outline ☆). +Re-mirrored `.dispatch/wire.reference.md`. typecheck 0/0, 1045 tests green (+27), biome clean, build OK. No open backend asks.)_ +**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** _Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._ -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** _Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp, not SQLite). FE resolves relative urls against the API base via a new pure `resolveImageUrl` helper; the optimistic @@ -34,7 +39,7 @@ Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `transport-contrac | Package | Used for | |---|---| | `@dispatch/ui-contract` | surfaces + surface WS protocol | -| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`)/`Computer`/`ComputerEntry` (SSH handoff #1) | +| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`TurnProviderRetryEvent`(transient retry-warning)/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`,+`starred`)/`Computer`/`ComputerEntry` (SSH handoff #1) | | `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + concurrency (`ConcurrencyLimitsResponse`/`SetConcurrencyLimitRequest`/`ConcurrencyLimitResponse`/`ConcurrencyStatusEntry`/`ConcurrencyStatusResponse`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): @@ -239,6 +244,35 @@ the finalized contract (`[email protected]`/`[email protected]`): `Workspace`/ (clear-to-inherit). Q1–Q8 decisions + full shapes in `backend-handoff-workspaces-reply.md`. FE re-pinned + re-mirrored; FE feature build in progress. +### Workspace starring (concurrency priority) → **CONSUMED ✅ (backend shipped `feature/workspace-star`; FE built + green)** + +**Backend contract change (additive to `[email protected]`, NO version bump):** `Workspace` gains a required +`starred: boolean` (defaults `false` on creation). A starred workspace's agents receive PRIORITY in the +concurrency limiter queue — they jump ahead of agents from non-starred workspaces (oldest-agent-first +within each group); takes effect immediately for already-queued agents (backend-owned, via +`setWorkspaceStarred` + `concurrencyService.notifyWorkspaceStarred`). Two dedicated endpoints (no body; +both create-on-miss; return the updated `Workspace`): +- `PUT /workspaces/:id/star` → star (400 on invalid slug; 200 `Workspace{starred:true}`). +- `DELETE /workspaces/:id/star` → unstar (400 on invalid slug; 200 `Workspace{starred:false}`). + +`PUT /workspaces/:id` does NOT accept a `starred` field (the dedicated endpoints are the only path). No +new `transport-contract` request/response types — both reuse `WorkspaceResponse`. + +**FE consumed (all green — typecheck 0/0, 1045 tests, biome clean, build OK):** +- `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 transformation). Both unit-tested. +- `store.svelte.ts`: `setStarred(id, starred)` — optimistic flip (the `$derived` sorted list re-orders + reactively) with error revert; no full refresh on success (avoids flicker). Re-mirrored `.dispatch/wire.reference.md`. +- `ui/WorkspaceCard.svelte`: a star toggle button (filled gold ★ when starred, outline ☆ when not; spinner + while in flight; `aria-pressed`/`aria-label`; tooltip notes concurrency priority). Clicking calls + `store.setStarred(ws.id, !ws.starred)`. +- Tests: http star/unstar (5), view-model sort+applyStarred (12), store optimistic+revert+re-sort (6), WorkspaceCard star button (4). + +**No open asks for the backend.** Live probe of the star endpoints against a running backend is the only +remaining human step (the `scripts/live-probe.ts` does not cover workspace endpoints; a manual `curl` or a +click in the home view confirms the round-trip). + ### CR-6 — Assign seq during generation → **RESOLVED ✅** (backend shipped; FE adoption pending) The backend now persists chunks **incrementally at step boundaries** during generation: diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index 19e53f8..18d8939 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -130,4 +130,60 @@ describe("createWorkspaceHttp", () => { const result = await http.delete("default"); expect(result).toEqual({ ok: false, error: "cannot delete default" }); }); + + it("star PUTs /star with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.star("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/star`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unstar DELETEs /star with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unstar("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/star`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("star surfaces a 400 for an invalid slug", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), + ); + const result = await http.star("UPPER"); + expect(result).toEqual({ ok: false, error: "invalid slug" }); + }); + + it("unstar surfaces the backend error on failure", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 500, body: { error: "Failed to unstar workspace" } }]), + ); + const result = await http.unstar("a"); + expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 01fe677..5673881 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -25,6 +25,8 @@ import type { * - `PUT /workspaces/:id/title` → rename * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) + * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority) + * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = @@ -38,6 +40,10 @@ export interface WorkspaceHttp { setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; + /** Star a workspace (concurrency priority). Create-on-miss; no body. */ + star(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unstar a workspace. Create-on-miss; no body. */ + unstar(id: string): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -141,6 +147,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async star(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/star`, { + method: "PUT", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Star failed" }; + } + }, + + async unstar(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/star`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unstar failed" }; + } + }, + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index 177e49b..dab1dec 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -8,7 +8,7 @@ export { WORKSPACE_SLUG_RE, workspacePath, } from "./logic/route"; -export { pageTitle, relativeTime } from "./logic/view-model"; +export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; export { createWorkspaceStore } from "./store.svelte"; export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte"; diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 86342e7..44d2f31 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,6 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { pageTitle, relativeTime } from "./view-model"; +import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 @@ -37,6 +37,7 @@ describe("pageTitle", () => { title, defaultCwd: null, defaultComputerId: null, + starred: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -66,3 +67,113 @@ describe("pageTitle", () => { expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); }); }); + +describe("sortWorkspaces", () => { + const entry = (id: string, starred: boolean, lastActivityAt: number): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred, + createdAt: 0, + lastActivityAt, + conversationCount: 0, + }); + + it("puts starred workspaces before unstarred", () => { + const list = [entry("plain", false, 9_000), entry("star", true, 1_000)]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("within the starred group, sorts by lastActivityAt desc", () => { + const list = [ + entry("old-star", true, 1_000), + entry("new-star", true, 5_000), + entry("plain", false, 9_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["new-star", "old-star", "plain"]); + }); + + it("within the unstarred group, sorts by lastActivityAt desc", () => { + const list = [ + entry("star", true, 1_000), + entry("old-plain", false, 1_000), + entry("new-plain", false, 5_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "new-plain", "old-plain"]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("plain", false, 9_000), entry("star", true, 1_000)]; + const sorted = sortWorkspaces(list); + expect(sorted).not.toBe(list); + // Input order is preserved (not mutated). + expect(list.map((w) => w.id)).toEqual(["plain", "star"]); + expect(sorted.map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("handles an empty list", () => { + expect(sortWorkspaces([])).toEqual([]); + }); + + it("is stable for equal lastActivityAt within a group", () => { + const list = [ + entry("first", false, 5_000), + entry("second", false, 5_000), + entry("third", false, 5_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["first", "second", "third"]); + }); +}); + +describe("applyStarred", () => { + const entry = (id: string, starred: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's starred flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyStarred(list, "b", true); + expect(next.map((w) => [w.id, w.starred])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyStarred(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.starred).toBe(false); + expect(next[0]?.starred).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyStarred([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("leaves the list unchanged when the id is absent (not yet loaded)", () => { + const list = [entry("a", false)]; + const next = applyStarred(list, "missing", true); + expect(next.map((w) => [w.id, w.starred])).toEqual([["a", false]]); + }); + + it("can revert by re-applying the previous value", () => { + const list = [entry("a", false)]; + const optimistic = applyStarred(list, "a", true); + expect(optimistic[0]?.starred).toBe(true); + const reverted = applyStarred(optimistic, "a", false); + expect(reverted[0]?.starred).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index 6dd64c6..b994b7c 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -20,6 +20,36 @@ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): } /** + * Sort workspaces for display: starred first, then most-recently-active. Pure: + * the list in, a NEW sorted array out (the input is not mutated). Starred + * workspaces jump to the top (the FE-side echo of their concurrency-priority); + * within each group (starred / not) `lastActivityAt` desc breaks ties, matching + * the backend's list ordering. Stable for equal `lastActivityAt`. + */ +export function sortWorkspaces<T extends WorkspaceEntry>(workspaces: readonly T[]): T[] { + return [...workspaces].sort((a, b) => { + if (a.starred !== b.starred) return a.starred ? -1 : 1; + return b.lastActivityAt - a.lastActivityAt; + }); +} + +/** + * Return a NEW list with the one workspace's `starred` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * star/unstar transformation shared by the apply + the error revert. A missing + * `id` (not yet in the list — e.g. starring a workspace the home view hasn't + * loaded) leaves the list unchanged; the backend's create-on-miss still applies + * server-side and a subsequent refresh reconciles. + */ +export function applyStarred<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + starred: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, starred } : w)); +} + +/** * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps * (a workspace just created) read as "now". diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 046c235..22d73b5 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,6 +1,7 @@ import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; +import { applyStarred, sortWorkspaces } from "./logic/view-model"; /** * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the @@ -9,7 +10,11 @@ import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; * owned by the composition root. */ export interface WorkspaceStore { - /** All workspaces (sorted by lastActivityAt desc by the backend). */ + /** + * All workspaces, sorted for display: starred first (their FE-side echo of + * concurrency-priority), then most-recently-active. The backing list is the + * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. + */ readonly list: readonly WorkspaceEntry[]; readonly loading: boolean; readonly error: string | null; @@ -23,18 +28,27 @@ export interface WorkspaceStore { setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; + /** + * Toggle a workspace's star (concurrency priority). Optimistic: the local + * `starred` flag flips immediately and the sorted list re-orders; on error it + * reverts to the prior value. No full refresh on success (avoids flicker). + */ + setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { let list = $state<readonly WorkspaceEntry[]>([]); + // Sorted view (starred first, then most-recent) — derived so it recomputes + // only when the backing list changes, not on every read. + let sorted = $derived(sortWorkspaces(list)); let loading = $state(false); let error = $state<string | null>(null); return { get list(): readonly WorkspaceEntry[] { - return list; + return sorted; }, get loading(): boolean { return loading; @@ -79,6 +93,18 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setStarred(id, starred): Promise<WorkspaceResult<Workspace>> { + const prev = list.find((w) => w.id === id)?.starred ?? false; + // Optimistic: flip immediately (the sorted getter re-orders reactively). + list = applyStarred(list, id, starred); + const result = starred ? await http.star(id) : await http.unstar(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyStarred(list, id, prev); + } + return result; + }, + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { const result = await http.delete(id); if (result.ok) void this.refresh(); 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); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 0ffc975..561b06c 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -83,6 +83,28 @@ if (!result.ok) computerError = result.error; } + // ── Star (concurrency priority) ────────────────────────────────────────────── + let savingStar = $state(false); + let starError = $state<string | null>(null); + + async function toggleStar(): Promise<void> { + if (savingStar) return; + savingStar = true; + starError = null; + try { + // Optimistic: the store flips `starred` immediately and re-sorts; revert + // on error is handled there. We read `ws.starred` for the target value. + const result = await store.setStarred(ws.id, !ws.starred); + if (!result.ok) starError = result.error; + } catch (err) { + // A throw (e.g. a rejected effect) — surface it; the store already + // reverted the optimistic flip if it got far enough to apply it. + starError = err instanceof Error ? err.message : "Star toggle failed"; + } finally { + savingStar = false; + } + } + // ── Delete ───────────────────────────────────────────────────────────────── let deleting = $state(false); @@ -123,6 +145,25 @@ > {/if} <span class="font-mono text-xs opacity-50">/{ws.id}</span> + <button + type="button" + class="btn btn-ghost btn-xs px-1" + disabled={savingStar} + aria-pressed={ws.starred} + aria-label={ws.starred ? "Unstar workspace" : "Star workspace"} + title={ws.starred + ? "Starred — its agents get concurrency priority. Click to unstar." + : "Star this workspace to give its agents concurrency priority."} + onclick={toggleStar} + > + {#if savingStar} + <span class="loading loading-spinner loading-xs"></span> + {:else if ws.starred} + <span class="text-warning" aria-hidden="true">★</span> + {:else} + <span class="opacity-40" aria-hidden="true">☆</span> + {/if} + </button> <span class="ml-auto text-xs opacity-50"> {ws.conversationCount} {ws.conversationCount === 1 ? "conversation" : "conversations"} @@ -148,6 +189,10 @@ <p class="text-xs text-error">{titleError}</p> {/if} + {#if starError} + <p class="text-xs text-error">{starError}</p> + {/if} + <div class="flex items-center gap-2"> <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> <input 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(); + }); }); |
