summaryrefslogtreecommitdiffhomepage
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
parenta59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff)
downloaddispatch-web-565217724f74d3303092c522d28f74f35132df3b.tar.gz
dispatch-web-565217724f74d3303092c522d28f74f35132df3b.zip
feat(workspace-active-indicator): show loading dots on workspace cards with active chats
-rw-r--r--backend-handoff.md9
-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
7 files changed, 252 insertions, 5 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index 5b41741..025d290 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,9 +5,12 @@
> **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
-_Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE
-re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests
-green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._
+_Last updated: 2026-06-27 (FE-only slice: **workspace-active indicator** — loading-dots on
+workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued
+conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a
+`hasActive?: (workspaceId: string) => boolean` port on `WorkspacesHome`/`WorkspaceCard` is wired at
+`src/App.svelte`. DaisyUI `loading-dots` (same as the tab/composer active indicator). **No backend /
+contract change** — no re-pin/re-mirror. typecheck 0/0, 1029 tests green (+10), biome clean, build OK.)_
**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
_Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP
paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp,
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}