When a prompt is sent, the only feedback is three bouncing dots. The user cannot tell whether the assistant is thinking, searching the web, reading their documents, or stuck. On a fast reply this is fine; on a slow one it looks frozen, and once the agentic work in chat_backend#63 lands — where a task can legitimately run for minutes — it becomes unusable.
We want the Gemini/ChatGPT pattern: a short, honest, changing status line describing what is actually happening right now.
Current state
The indicator is rendered inline in ConversationDetailCard, triggered purely by an empty message string:
The placeholder is created optimistically on submit (AsyncDashboard2.tsx:387-390) as a ConversationPrompt with message: "". Dots show until the first token arrives, at which point stateMessage becomes non-empty and text replaces them.
MessageContext already tracks isGeneratingMessage / isGeneratingRef (MessageContext.tsx:47-54, 168-180) but AsyncDashboard2 never reads it. There is a phase counter, messageResponsePart, driven by sentinel strings (MessageContext.tsx:152-194):
Value
Set by
1
CONVERSATION_ID
2
START_OF_THE_STREAM_ENDER_GAME_42
0
END_OF_THE_STREAM_ENDER_GAME_42
So the frontend has no idea what the backend is doing between "connected" and "first token" — and the backend never tells it.
Dependency
This needs backend cooperation. The status frames must come from the server, not be faked on a timer — a fabricated "Searching the web…" shown while the backend skipped search would be worse than the dots.
Coordinate the frame protocol with chat_backend#62 (citation frames) and chat_backend#63 (agent step frames). All three add frames to the same WebSocket stream. Agree one versioned envelope rather than adding more bare sentinel strings:
{"v":1,"type":"status","data":{"stage":"searching","label":"Searching the web","detail":"Taylor Swift Travis Kelce wedding"}}
The frontend must ignore unknown type values and unknown stage values so backend and frontend can deploy independently.
Proposed implementation
1. Parse structured frames in MessageContext
In the onmessage handler (MessageContext.tsx:146-200), attempt a JSON parse before the existing sentinel-string checks. If the payload is a versioned envelope, route it by type; otherwise fall through to today's logic unchanged. Existing behaviour must be untouched when the backend sends no frames.
The frontend must render an unknown stage's label verbatim rather than dropping it, so chat_backend#63 can add agent stages without a frontend release.
4. New ActivityIndicator component
Replace the three-dot block in ConversationDetailCard. Requirements:
Show current label with a subtle animated affordance (spinner or shimmer), plus detail as smaller secondary text when present.
Show completed stages for the turn collapsed above the current one, each with a check — this is the "rolling up" quality the user is after.
Animate label transitions; never flash. Enforce a minimum display time of ~400ms per stage so fast backends don't produce a strobe.
Fall back to today's three dots when activityStage is null — covers an older backend and the gap before the first frame.
Theme-aware (light/dark), matching the existing agent bubble styling.
Mobile-safe: truncate detail with ellipsis, never widen the bubble past the viewport.
5. Handle long-running and stalled turns
Show elapsed time once a turn exceeds ~10s.
If no frame and no token arrive for ~30s, show a soft "still working…" note rather than leaving a frozen label.
Keep working with the existing streamInterrupted banner (AsyncDashboard2.tsx:542-551); the activity indicator must clear when that fires.
6. Accessibility
role="status" with aria-live="polite" so screen readers announce stage changes.
Respect prefers-reduced-motion: no spinner or shimmer animation, just the text.
7. Analytics
Add events per ANALYTICS.md conventions for stage durations, so we can measure where slow turns actually spend their time.
Acceptance criteria
MessageContext parses versioned JSON frames and ignores unknown type and unknown stage values without breaking the stream.
Sentinel-string handling (CONVERSATION_ID, START_OF_THE_STREAM_ENDER_GAME_42, END_OF_THE_STREAM_ENDER_GAME_42) still works unchanged.
With a backend that emits no status frames, behaviour is identical to today — three dots, then text. Covered by test.
activityStage, activityLabel, activityDetail, and activityHistory are exposed and cleared on completion, on error, and on new submit.
ActivityIndicator replaces the inline three-dot block in ConversationDetailCard and shows the current label plus optional detail.
Completed stages for the current turn render collapsed above the active one with a completion mark.
An unknown stage renders its server-supplied label verbatim rather than being dropped. Covered by test.
Each stage is displayed for a minimum of ~400ms; rapid stage sequences do not strobe.
Elapsed time appears after ~10s; a "still working" note appears after ~30s of silence.
Indicator clears when streamInterrupted fires and does not linger.
Correct in both light and dark themes; no horizontal overflow at 320px width.
Verified on Android via Capacitor.
The frame envelope matches what chat_backend#62 and #63 emit; no conflicting type values across the three.
Unit tests cover the full frame sequence, the no-frame fallback, unknown stages, and cleanup on error.
Out of scope
Nested sub-agent step display — arrives with chat_backend#63; the unknown-stage passthrough is what keeps this issue forward-compatible with it.
Rendering citations — separate work, same frame protocol.
## Problem
When a prompt is sent, the only feedback is three bouncing dots. The user cannot tell whether the assistant is thinking, searching the web, reading their documents, or stuck. On a fast reply this is fine; on a slow one it looks frozen, and once the agentic work in `chat_backend`#63 lands — where a task can legitimately run for minutes — it becomes unusable.
We want the Gemini/ChatGPT pattern: a short, honest, changing status line describing what is actually happening right now.
## Current state
The indicator is rendered inline in `ConversationDetailCard`, triggered purely by an empty message string:
```tsx
// src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx:160-171
if (message.length === 0) {
return (
<MessageContainer $isUser={false}>
<Bubble $isUser={false}>
<LoadingContainer>
<LoadingDot />
<LoadingDot />
<LoadingDot />
</LoadingContainer>
</Bubble>
</MessageContainer>
);
}
```
The placeholder is created optimistically on submit (`AsyncDashboard2.tsx:387-390`) as a `ConversationPrompt` with `message: ""`. Dots show until the first token arrives, at which point `stateMessage` becomes non-empty and text replaces them.
`MessageContext` already tracks `isGeneratingMessage` / `isGeneratingRef` (`MessageContext.tsx:47-54`, `168-180`) but **`AsyncDashboard2` never reads it**. There is a phase counter, `messageResponsePart`, driven by sentinel strings (`MessageContext.tsx:152-194`):
| Value | Set by |
|---|---|
| `1` | `CONVERSATION_ID` |
| `2` | `START_OF_THE_STREAM_ENDER_GAME_42` |
| `0` | `END_OF_THE_STREAM_ENDER_GAME_42` |
So the frontend has no idea what the backend is doing between "connected" and "first token" — and the backend never tells it.
## Dependency
This needs backend cooperation. The status frames must come from the server, not be faked on a timer — a fabricated "Searching the web…" shown while the backend skipped search would be worse than the dots.
Coordinate the frame protocol with `chat_backend`#62 (citation frames) and `chat_backend`#63 (agent step frames). All three add frames to the same WebSocket stream. Agree **one** versioned envelope rather than adding more bare sentinel strings:
```json
{"v": 1, "type": "status", "data": {"stage": "searching", "label": "Searching the web", "detail": "Taylor Swift Travis Kelce wedding"}}
```
The frontend must ignore unknown `type` values and unknown `stage` values so backend and frontend can deploy independently.
## Proposed implementation
### 1. Parse structured frames in `MessageContext`
In the `onmessage` handler (`MessageContext.tsx:146-200`), attempt a JSON parse before the existing sentinel-string checks. If the payload is a versioned envelope, route it by `type`; otherwise fall through to today's logic unchanged. Existing behaviour must be untouched when the backend sends no frames.
### 2. Add status state
Expose from `MessageContext`:
- `activityStage: string | null` — current stage key
- `activityLabel: string | null` — human-readable label
- `activityDetail: string | null` — optional sub-text (the search query, document name, etc.)
- `activityHistory: {stage, label, startedAt, finishedAt}[]` — completed stages for this turn
Clear all of it on `END_OF_THE_STREAM_ENDER_GAME_42`, on error, and when a new prompt is submitted.
### 3. Stage vocabulary
Start with a set that maps to what the backend actually does today and after #62:
| Stage | Label | Emitted when |
|---|---|---|
| `queued` | Getting started | Message accepted |
| `moderating` | Checking your request | Moderation classifier running |
| `evaluating` | Evaluating the question | Grounding decision (#62) |
| `searching` | Searching the web | Search provider called; `detail` = the query |
| `reading_sources` | Reading sources | Ranking/extracting results |
| `retrieving_docs` | Searching your documents | Chroma retrieval |
| `analysing` | Analysing your file | Data-analysis path |
| `refining` | Refining the answer | Final generation begins |
| `writing` | *(no label — tokens are streaming)* | First token |
The frontend must render an unknown stage's `label` verbatim rather than dropping it, so `chat_backend`#63 can add agent stages without a frontend release.
### 4. New `ActivityIndicator` component
Replace the three-dot block in `ConversationDetailCard`. Requirements:
- Show current label with a subtle animated affordance (spinner or shimmer), plus `detail` as smaller secondary text when present.
- Show completed stages for the turn collapsed above the current one, each with a check — this is the "rolling up" quality the user is after.
- Animate label transitions; never flash. Enforce a **minimum display time of ~400ms per stage** so fast backends don't produce a strobe.
- Fall back to today's three dots when `activityStage` is `null` — covers an older backend and the gap before the first frame.
- Theme-aware (light/dark), matching the existing agent bubble styling.
- Mobile-safe: truncate `detail` with ellipsis, never widen the bubble past the viewport.
### 5. Handle long-running and stalled turns
- Show elapsed time once a turn exceeds ~10s.
- If no frame and no token arrive for ~30s, show a soft "still working…" note rather than leaving a frozen label.
- Keep working with the existing `streamInterrupted` banner (`AsyncDashboard2.tsx:542-551`); the activity indicator must clear when that fires.
### 6. Accessibility
- `role="status"` with `aria-live="polite"` so screen readers announce stage changes.
- Respect `prefers-reduced-motion`: no spinner or shimmer animation, just the text.
### 7. Analytics
Add events per `ANALYTICS.md` conventions for stage durations, so we can measure where slow turns actually spend their time.
## Acceptance criteria
- [ ] `MessageContext` parses versioned JSON frames and ignores unknown `type` and unknown `stage` values without breaking the stream.
- [ ] Sentinel-string handling (`CONVERSATION_ID`, `START_OF_THE_STREAM_ENDER_GAME_42`, `END_OF_THE_STREAM_ENDER_GAME_42`) still works unchanged.
- [ ] With a backend that emits **no** status frames, behaviour is identical to today — three dots, then text. Covered by test.
- [ ] `activityStage`, `activityLabel`, `activityDetail`, and `activityHistory` are exposed and cleared on completion, on error, and on new submit.
- [ ] `ActivityIndicator` replaces the inline three-dot block in `ConversationDetailCard` and shows the current label plus optional detail.
- [ ] Completed stages for the current turn render collapsed above the active one with a completion mark.
- [ ] An unknown stage renders its server-supplied `label` verbatim rather than being dropped. Covered by test.
- [ ] Each stage is displayed for a minimum of ~400ms; rapid stage sequences do not strobe.
- [ ] Elapsed time appears after ~10s; a "still working" note appears after ~30s of silence.
- [ ] Indicator clears when `streamInterrupted` fires and does not linger.
- [ ] `role="status"` / `aria-live="polite"` announce changes; `prefers-reduced-motion` disables animation.
- [ ] Correct in both light and dark themes; no horizontal overflow at 320px width.
- [ ] Verified on Android via Capacitor.
- [ ] The frame envelope matches what `chat_backend`#62 and #63 emit; no conflicting `type` values across the three.
- [ ] Unit tests cover the full frame sequence, the no-frame fallback, unknown stages, and cleanup on error.
## Out of scope
- Nested sub-agent step display — arrives with `chat_backend`#63; the unknown-stage passthrough is what keeps this issue forward-compatible with it.
- Rendering citations — separate work, same frame protocol.
Citations UI (explicitly out of scope here) tracked separately: #98.
Share the versioned WS frame parser with that work; backend citations from chat_backend#62.
Citations UI (explicitly out of scope here) tracked separately: [#98](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/98).
Share the versioned WS frame parser with that work; backend citations from [chat_backend#62](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/62).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
When a prompt is sent, the only feedback is three bouncing dots. The user cannot tell whether the assistant is thinking, searching the web, reading their documents, or stuck. On a fast reply this is fine; on a slow one it looks frozen, and once the agentic work in
chat_backend#63 lands — where a task can legitimately run for minutes — it becomes unusable.We want the Gemini/ChatGPT pattern: a short, honest, changing status line describing what is actually happening right now.
Current state
The indicator is rendered inline in
ConversationDetailCard, triggered purely by an empty message string:The placeholder is created optimistically on submit (
AsyncDashboard2.tsx:387-390) as aConversationPromptwithmessage: "". Dots show until the first token arrives, at which pointstateMessagebecomes non-empty and text replaces them.MessageContextalready tracksisGeneratingMessage/isGeneratingRef(MessageContext.tsx:47-54,168-180) butAsyncDashboard2never reads it. There is a phase counter,messageResponsePart, driven by sentinel strings (MessageContext.tsx:152-194):1CONVERSATION_ID2START_OF_THE_STREAM_ENDER_GAME_420END_OF_THE_STREAM_ENDER_GAME_42So the frontend has no idea what the backend is doing between "connected" and "first token" — and the backend never tells it.
Dependency
This needs backend cooperation. The status frames must come from the server, not be faked on a timer — a fabricated "Searching the web…" shown while the backend skipped search would be worse than the dots.
Coordinate the frame protocol with
chat_backend#62 (citation frames) andchat_backend#63 (agent step frames). All three add frames to the same WebSocket stream. Agree one versioned envelope rather than adding more bare sentinel strings:The frontend must ignore unknown
typevalues and unknownstagevalues so backend and frontend can deploy independently.Proposed implementation
1. Parse structured frames in
MessageContextIn the
onmessagehandler (MessageContext.tsx:146-200), attempt a JSON parse before the existing sentinel-string checks. If the payload is a versioned envelope, route it bytype; otherwise fall through to today's logic unchanged. Existing behaviour must be untouched when the backend sends no frames.2. Add status state
Expose from
MessageContext:activityStage: string | null— current stage keyactivityLabel: string | null— human-readable labelactivityDetail: string | null— optional sub-text (the search query, document name, etc.)activityHistory: {stage, label, startedAt, finishedAt}[]— completed stages for this turnClear all of it on
END_OF_THE_STREAM_ENDER_GAME_42, on error, and when a new prompt is submitted.3. Stage vocabulary
Start with a set that maps to what the backend actually does today and after #62:
queuedmoderatingevaluatingsearchingdetail= the queryreading_sourcesretrieving_docsanalysingrefiningwritingThe frontend must render an unknown stage's
labelverbatim rather than dropping it, sochat_backend#63 can add agent stages without a frontend release.4. New
ActivityIndicatorcomponentReplace the three-dot block in
ConversationDetailCard. Requirements:detailas smaller secondary text when present.activityStageisnull— covers an older backend and the gap before the first frame.detailwith ellipsis, never widen the bubble past the viewport.5. Handle long-running and stalled turns
streamInterruptedbanner (AsyncDashboard2.tsx:542-551); the activity indicator must clear when that fires.6. Accessibility
role="status"witharia-live="polite"so screen readers announce stage changes.prefers-reduced-motion: no spinner or shimmer animation, just the text.7. Analytics
Add events per
ANALYTICS.mdconventions for stage durations, so we can measure where slow turns actually spend their time.Acceptance criteria
MessageContextparses versioned JSON frames and ignores unknowntypeand unknownstagevalues without breaking the stream.CONVERSATION_ID,START_OF_THE_STREAM_ENDER_GAME_42,END_OF_THE_STREAM_ENDER_GAME_42) still works unchanged.activityStage,activityLabel,activityDetail, andactivityHistoryare exposed and cleared on completion, on error, and on new submit.ActivityIndicatorreplaces the inline three-dot block inConversationDetailCardand shows the current label plus optional detail.labelverbatim rather than being dropped. Covered by test.streamInterruptedfires and does not linger.role="status"/aria-live="polite"announce changes;prefers-reduced-motiondisables animation.chat_backend#62 and #63 emit; no conflictingtypevalues across the three.Out of scope
chat_backend#63; the unknown-stage passthrough is what keeps this issue forward-compatible with it.Citations UI (explicitly out of scope here) tracked separately: #98.
Share the versioned WS frame parser with that work; backend citations from chat_backend#62.