From 60aa5dc48b6af502f88befd7d1517ab52cf6c60f Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Sun, 28 Jun 2026 13:18:49 +0900
Subject: feat(workspaces): star toggle for concurrency priority
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Backend (feature/workspace-star) shipped Workspace.starred: boolean (additive
to wire@0.12.0, 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).
---
 src/features/workspaces/ui/WorkspaceCard.svelte | 45 +++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

(limited to 'src/features/workspaces/ui/WorkspaceCard.svelte')

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
-- 
cgit v1.2.3

