diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 15:26:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 15:26:40 +0900 |
| commit | 7f2d66d5c132b45f7d01b0d9107f341094b9c904 (patch) | |
| tree | fae4f560db3790feaf67be615e969479af316957 /src | |
| parent | 22a8dbf96dda066dc5027694b1fe512df9f3fc11 (diff) | |
| parent | 565217724f74d3303092c522d28f74f35132df3b (diff) | |
| download | dispatch-web-7f2d66d5c132b45f7d01b0d9107f341094b9c904.tar.gz dispatch-web-7f2d66d5c132b45f7d01b0d9107f341094b9c904.zip | |
Merge branch 'feature/workspace-active-indicator' into predev
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.svelte | 7 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 31 | ||||
| -rw-r--r-- | src/app/store.test.ts | 116 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 20 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 59 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 15 |
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 e1a11e0..1048d87 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1734,6 +1734,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} |
