summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 15:29:55 +0900
committerAdam Malczewski <[email protected]>2026-06-28 15:29:55 +0900
commitded7cc4ebf872556b4ab16bac81c1b551d8e44da (patch)
tree0f5cbed2405f83a819032a551295490b70e31d1d /src/features/workspaces/ui
parent3899ecef48325014a3bb5d6333a7a3e1fc34296a (diff)
parent60aa5dc48b6af502f88befd7d1517ab52cf6c60f (diff)
downloaddispatch-web-ded7cc4ebf872556b4ab16bac81c1b551d8e44da.tar.gz
dispatch-web-ded7cc4ebf872556b4ab16bac81c1b551d8e44da.zip
Merge branch 'feature/workspace-star' into predev
# Conflicts: # backend-handoff.md # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/workspaces/ui')
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte45
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts106
2 files changed, 151 insertions, 0 deletions
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();
});
});