summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 23:13:31 +0900
committerAdam Malczewski <[email protected]>2026-06-28 14:41:33 +0900
commit565217724f74d3303092c522d28f74f35132df3b (patch)
tree8a2dacd4c4949be1ec0361b91d5d865f71cbacea /src
parenta59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff)
downloaddispatch-web-565217724f74d3303092c522d28f74f35132df3b.tar.gz
dispatch-web-565217724f74d3303092c522d28f74f35132df3b.zip
feat(workspace-active-indicator): show loading dots on workspace cards with active chats
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte7
-rw-r--r--src/app/store.svelte.ts31
-rw-r--r--src/app/store.test.ts116
-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
6 files changed, 246 insertions, 2 deletions
diff --git a/src/App.svelte b/src/App.svelte
index 713c844..27c9271 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -68,7 +68,12 @@
</script>
{#if route.kind === "home"}
- <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} />
+ <WorkspacesHome
+ store={workspaceStore}
+ onNavigate={navigate}
+ computers={store.computers}
+ hasActive={(id) => store.workspaceHasActiveConversations(id)}
+ />
{:else}
<App {store} onNavigate={navigate} />
{/if}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index ead27a6..82d755d 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -351,6 +351,16 @@ export interface AppStore {
*/
conversationStatus(conversationId: string): ConversationStatus | undefined;
/**
+ * Whether at least one conversation in the given workspace is currently
+ * active or queued (generating / waiting for a concurrency slot) — drives
+ * the loading-dots indicator on workspace cards. Backed by a once-derived
+ * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
+ * so this is an O(1) lookup, not a per-card scan of the full tab list.
+ * Reactive: the set is a `$derived`, so a Svelte template expression calling
+ * this re-runs when the tab set or status map changes.
+ */
+ workspaceHasActiveConversations(workspaceId: string): boolean;
+ /**
* Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
* localStorage and propagates to every live chat store (trim if lower,
* deferred via the unload gate while a reader is scrolled up; no-op if
@@ -966,6 +976,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// fetched on connect). Keyed by conversationId.
let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
+ // The set of workspaces with ≥1 active/queued conversation, derived ONCE
+ // (not recomputed per card). Every active/queued conversation has an open
+ // tab stamped with its workspace, so the tabs are the conversation→workspace
+ // map; cross-reference with the lifecycle statuses. `$derived` recomputes
+ // lazily when the tab set or status map changes, so each
+ // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
+ // of the full tab list per card.
+ const activeWorkspaces = $derived.by(() => {
+ const out = new Set<string>();
+ for (const tab of tabsStore.tabs) {
+ const status = conversationStatuses.get(tab.conversationId);
+ if (status === "active" || status === "queued") out.add(tab.workspaceId);
+ }
+ return out;
+ });
+
/**
* Fetch `GET /conversations?status=active,idle` on connect to restore the
* tab bar across devices. Merges: opens tabs for conversations not already
@@ -1255,6 +1281,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
conversationStatus(conversationId: string): ConversationStatus | undefined {
return conversationStatuses.get(conversationId);
},
+ workspaceHasActiveConversations(workspaceId: string): boolean {
+ // O(1) lookup into the once-derived `activeWorkspaces` set; false when the
+ // workspace has no active/queued conversation (or none at all).
+ return activeWorkspaces.has(workspaceId);
+ },
get currentConversationId(): string {
return workspaceConversationId();
},
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 2cf473c..7fb1469 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1730,6 +1730,122 @@ describe("createAppStore", () => {
expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true);
store.dispose();
});
+
+ // ── workspaceHasActiveConversations (workspace-card active indicator) ─────
+
+ it("workspaceHasActiveConversations is false when no conversation is active", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ // The conversation is freshly created — the backend hasn't reported it as
+ // active yet, so the workspace has no active conversation.
+ expect(store.conversationStatus(convId)).toBeUndefined();
+ expect(store.workspaceHasActiveConversations("default")).toBe(false);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations is true when a conversation in the workspace is active", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations is true when a conversation is queued (waiting for a slot)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "queued",
+ workspaceId: "default",
+ });
+
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations goes back to false when the conversation goes idle", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "idle",
+ workspaceId: "default",
+ });
+ expect(store.workspaceHasActiveConversations("default")).toBe(false);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations scopes to the given workspace (ignores other workspaces)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A cross-device active conversation in workspace "proj-a".
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "proj-a-conv",
+ status: "active",
+ workspaceId: "proj-a",
+ });
+
+ // proj-a is active; proj-b is not (no active conversation there).
+ expect(store.workspaceHasActiveConversations("proj-a")).toBe(true);
+ expect(store.workspaceHasActiveConversations("proj-b")).toBe(false);
+ store.dispose();
+ });
});
describe("createAppStore — vision settings (global)", () => {
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}