summaryrefslogtreecommitdiffhomepage
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
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).
-rw-r--r--.dispatch/wire.reference.md14
-rw-r--r--GLOSSARY.md1
-rw-r--r--backend-handoff.md38
-rw-r--r--src/features/workspaces/adapter/http.test.ts56
-rw-r--r--src/features/workspaces/adapter/http.ts30
-rw-r--r--src/features/workspaces/index.ts2
-rw-r--r--src/features/workspaces/logic/view-model.test.ts113
-rw-r--r--src/features/workspaces/logic/view-model.ts30
-rw-r--r--src/features/workspaces/store.svelte.ts30
-rw-r--r--src/features/workspaces/store.test.ts145
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte45
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts108
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();
+ });
});