summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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.ts106
9 files changed, 553 insertions, 4 deletions
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 1825b7d..6de4109 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -96,6 +96,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);
@@ -143,6 +165,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"}
@@ -168,6 +209,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 1874391..28aed88 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,
@@ -193,5 +200,104 @@ describe("WorkspaceCard", () => {
},
});
expect(seen).toEqual(["proj-x"]);
+ 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();
});
});