docs(webui): running-conversation indicator design spec

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-12 11:55:17 +08:00
parent 2ee0e617ef
commit f992cd213f
@@ -0,0 +1,100 @@
# 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>`:
```ts
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).