summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 15:26:40 +0900
committerAdam Malczewski <[email protected]>2026-06-28 15:26:40 +0900
commit7f2d66d5c132b45f7d01b0d9107f341094b9c904 (patch)
treefae4f560db3790feaf67be615e969479af316957 /src/features
parent22a8dbf96dda066dc5027694b1fe512df9f3fc11 (diff)
parent565217724f74d3303092c522d28f74f35132df3b (diff)
downloaddispatch-web-7f2d66d5c132b45f7d01b0d9107f341094b9c904.tar.gz
dispatch-web-7f2d66d5c132b45f7d01b0d9107f341094b9c904.zip
Merge branch 'feature/workspace-active-indicator' into predev
Diffstat (limited to 'src/features')
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte20
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts59
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte15
3 files changed, 93 insertions, 1 deletions
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 0ffc975..1825b7d 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -11,14 +11,27 @@
store,
onNavigate,
computers,
+ hasActive,
}: {
ws: WorkspaceEntry;
store: WorkspaceStore;
onNavigate: (path: string) => void;
/** Discovered computers (`GET /computers`), for the default-computer dropdown. */
computers: readonly ComputerEntry[];
+ /**
+ * Optional port: returns whether the workspace has at least one active
+ * (generating / queued) conversation — drives a loading-dots indicator on
+ * the card. Wired by the composition root to the app store's
+ * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests).
+ */
+ hasActive?: (workspaceId: string) => boolean;
} = $props();
+ // Whether at least one conversation in this workspace is currently active
+ // (generating). Reactive: the composition-root port reads the app store's
+ // reactive tab set + lifecycle statuses, so this re-derives on change.
+ const active = $derived(hasActive?.(ws.id) ?? false);
+
// ── Title: double-click to rename inline ──────────────────────────────────
let editingTitle = $state(false);
let titleDraft = $state("");
@@ -122,6 +135,13 @@
ondblclick={startEditTitle}>{ws.title}</span
>
{/if}
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0 text-primary"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span
+ >
+ {/if}
<span class="font-mono text-xs opacity-50">/{ws.id}</span>
<span class="ml-auto text-xs opacity-50">
{ws.conversationCount}
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index 0d03b8e..1874391 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -135,4 +135,63 @@ describe("WorkspaceCard", () => {
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("/my-ws");
});
+
+ // ── Active indicator (loading dots) ──────────────────────────────────────
+
+ it("shows no loading-dots when no hasActive port is given", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+ expect(container.querySelector(".loading-dots")).toBeNull();
+ });
+
+ it("shows no loading-dots when hasActive returns false", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: () => false,
+ },
+ });
+ expect(container.querySelector(".loading-dots")).toBeNull();
+ });
+
+ it("shows loading-dots when hasActive returns true", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: () => true,
+ },
+ });
+ const dots = container.querySelector(".loading-dots");
+ expect(dots).not.toBeNull();
+ // Accessible label ties the indicator to the workspace-active concept.
+ expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations");
+ });
+
+ it("forwards the workspace id to hasActive", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const seen: string[] = [];
+ render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry({ id: "proj-x" }),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: (id: string) => {
+ seen.push(id);
+ return false;
+ },
+ },
+ });
+ expect(seen).toEqual(["proj-x"]);
+ });
});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 02e92b4..d97eab7 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -9,10 +9,17 @@
store,
onNavigate,
computers,
+ hasActive,
}: {
store: WorkspaceStore;
onNavigate: (path: string) => void;
computers: readonly ComputerEntry[];
+ /**
+ * Optional port forwarded to each {@link WorkspaceCard}: whether the
+ * workspace has at least one active (generating / queued) conversation.
+ * Wired by the composition root to the app store. Absent → no indicator.
+ */
+ hasActive?: (workspaceId: string) => boolean;
} = $props();
onMount(() => {
@@ -89,7 +96,13 @@
{:else}
<ul class="flex flex-col gap-2">
{#each store.list as ws (ws.id)}
- <WorkspaceCard {ws} {store} {onNavigate} {computers} />
+ <WorkspaceCard
+ {ws}
+ {store}
+ {onNavigate}
+ {computers}
+ {...(hasActive ? { hasActive } : {})}
+ />
{/each}
</ul>
{/if}