Files
EvoScientist-WebUI/docs/superpowers/specs/2026-08-12-running-conversation-indicator-design.md
T
2026-08-12 11:55:17 +08:00

3.9 KiB

Running-Conversation Indicator — Design

Date: 2026-08-12 Status: Approved (approach A: wire existing infrastructure)

Goal

A conversation whose run is executing gives no visual signal today. Add a lightweight pulsing "running" indicator in two places:

  1. Thread list rows — a dot next to the title of any thread with an active run (including runs started by other tabs or scheduled tasks).
  2. Global header — a dot next to the logo/wordmark whenever the currently open conversation is running, shown in all views (chat, Skills, Memory, Schedule) so switching away doesn't hide that work is in flight.

Decisions locked in during brainstorming:

  • Style: lightweight pulsing dot only — no banner text, no extra stop button (the composer's stop affordance already exists).
  • Approach: wire up the already-built (but currently unconsumed) running state infrastructure. No backend changes.

Existing infrastructure (why no backend work is needed)

  • useChat.ts already calls setThreadRunning(threadId, isRunLoading) on its own run lifecycle — a zero-latency local store (useThreads.ts:99-126, covered by useThreads.running.test.ts).
  • useBusyThreadIds() (useThreads.ts:78-97) polls /api/conversations?status=busy&limit=100 on a 10s heartbeat, covering runs started out-of-band (other tabs, scheduled tasks).
  • Neither hook is consumed by any UI today — this feature is wiring + rendering.

Components

1. useRunningThreadIds() — new hook in src/app/hooks/useThreads.ts

Merges the two sources into one ReadonlySet<string>:

const local = useLocalRunningThreadIds();
const busy = useBusyThreadIds();
return useMemo(() => new Set([...busy, ...local]), [busy, local]);

Single call site per consumer; the merge is the only new logic. SWR dedupes the heartbeat request across multiple mounts, so calling this hook from both ThreadList and page.tsx is safe.

2. RunningDot — new presentational component

src/app/components/RunningDot.tsx. Standard ping pattern:

  • outer span.relative.flex.size-2 with role="status", i18n aria-label and title;
  • inner animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75;
  • inner solid relative inline-flex size-2 rounded-full bg-blue-500.

Blue matches the existing STATUS_COLORS.busy in ThreadList. The component takes the translated label as a prop so callers use their own i18n domain.

3. Thread list row (src/app/components/ThreadList.tsx)

  • Call useRunningThreadIds() once at the top of ThreadList.
  • In renderThreadCard, render <RunningDot /> inside the title <h3>, after the pin icon and before the title text, when runningIds.has(thread.id).

4. Global header (src/app/[locale]/page.tsx)

  • Call useRunningThreadIds() in HomePageInner.
  • Render <RunningDot /> in the header's left cluster (after the logo/wordmark block) when threadId && runningIds.has(threadId).
  • No view gating: the dot shows in every view while the open thread runs.

i18n

One new message per domain, both locales:

  • threadList.running — e.g. en "Running", zh "正在执行" (used by the row dot).
  • header.running — same text (used by the header dot).

Error handling

Nothing new. A failed busy heartbeat yields an empty busy set (dot hides until the next successful poll); the local store still covers this client's own runs, so the common case never regresses on a heartbeat failure.

Testing

  • Extend the pattern of useThreads.running.test.ts with a unit test for useRunningThreadIds: local-only, busy-only, and merged membership.
  • Existing suites (useThreads.running.test.ts, tsc, eslint) must stay green.

Out of scope

  • Banner/text prompts, header stop button.
  • Push/SSE real-time busy events (10s heartbeat is sufficient).
  • Per-row indicators for interrupted/error status (already surfaced via the "Requiring Attention" group and status filter).