Repository navigation
feat(ui): real-time status language across stages — connecting indicator + stable thinking/outputting signal #646
Description
Background
After #642 unified streaming and settled answers onto a single render path, manual testing surfaced a class of real-time feedback that still needs work: across the stages where the app communicates with the user (connecting → thinking → outputting → tool running→done), the "in progress" signal is either missing, unstable, or too fleeting, so the user can't reliably tell which stage the system is in.
This is the tracking issue for a consistent cross-stage real-time-status language, folded under the #546 visual-polish umbrella. Its first PR delivers one unified indicator plus the tool run→done seam (see Scope below).
Current state (verified in code)
-
Connecting — missing. The streaming slot only has
streaming/drainingphases (app-shell-session-events.ts), and the composer'sstreamingprop isactiveStreamingLive, which requires the first token to have arrived (app-shell.tsx). So the window between "sent" and "first token" — while the LLM connection is being established — has no dedicated signal, only the composer's brief "正在发送…". Note:turn_started/model_stream_startedare RunTrace events, not SessionEvents, andmodel_stream_startedfires before any network request — so "connecting" and "thinking" are not observably distinguishable; the real wait is one window (model_stream_started → first delta), armed locally fromsend(). -
Thinking / outputting — unstable. Two separate signals that don't cover the window reliably:
- The live "深度思考" disclosure shimmers its title (
TextShimmer) only when a live thinking item exists, i.e. a thinking model that has already emitted reasoning tokens. Non-thinking models, and the gap before the first reasoning token, show nothing — so "正在思考" does not display stably. - The composer's "Maka 正在回答…" hint only appears once answer text streams. Before the first answer token there is no "outputting" signal.
Net: for a stretch of every turn there is no clear thinking/outputting indicator.
- The live "深度思考" disclosure shimmers its title (
-
Tool run→done seam — too fleeting, no transition. The running-tool shimmer is correctly wired (refactor(ui): unify streaming (live) and committed answer onto one render path #642 verified running/pending tools render
data-slot="text-shimmer"with a live animation), but it flashes by on sub-second tools and there is no explicit running→done transition, so in practice it feels like "I can't tell if a tool ran."
Goal
One consistent real-time status language across stages — connecting / thinking / outputting / tool running→done — so the user always knows which stage the system is in. Two properties matter: coverage (no dead window with zero signal) and consistency (the same visual vocabulary reused across stages, not four ad-hoc treatments).
Scope (first PR — one status-language change)
A single unified indicator plus the tool seam, sharing one debounced "legible working→settled" principle. The connecting-vs-thinking two-state split and IPC forwarding are not built (unobservable). Design:
正在处理…indicator at the tail of the in-flight turn, covering every window where the model is being awaited with nothing streaming (turn head and the resume gap after a tool completes). Reuses the existing timeline live vocabulary (16px neutral icon +TextShimmer). Rendered as a transient view state inLiveStreamingEntries—materialize.tsis not touched.- State model: a
turnActiveboolean (armed in localsend(), cleared oncomplete/error/abort, guarded by turnId against a latecomplete); visibility is a pure derivationturnActive && !streaming && !thinking && !in-flight tools, with a ~200ms delay on the composite's rising edge so fast responses never flash it. Timer lives in a React hook, not the event-handler closure. Visibility also gates onactiveSession.status === 'running'so a backgrounded session self-heals. - Tool run→done seam: the running row is visible immediately but only shimmers after ~200ms (sub-second tools never shimmer), and settles running→done via a
data-attribute + existing CSS transition (no new keyframe). Same debounce principle as the indicator. - Composer Stop is made available during the wait window (its
streamingprop becomes the composite condition), since that window is exactly when a user wants to stop.
Out of scope: IPC / lifecycle-event forwarding; a global "only one shimmer at a time" invariant (multiple simultaneous shimmers are acceptable); artifact preview / browser panel / prose — those stay in #546 PR6.
Verification
Pure-derivation + injectable-scheduler unit tests (fake timers: 200ms rising edge, first-content-within-200ms never shows, mid-turn auto-recover, abort/error/complete clear, turnId guard); event→state wiring via the existing streaming-handoff harness; visual-smoke staged screenshots (indicator shown; tool running-shimmer and completed end-points). No E2E (timing-flaky and can't judge feel); the 200ms tuning and the settle transition are a one-time manual feel pass.
Notes
- Split out of refactor(ui): unify streaming (live) and committed answer onto one render path #642 manual testing. Issue 1 (deep-thinking live→settled height jump) and issue 2 (folded-trow chevron next to the label) were fixed in refactor(ui): unify streaming (live) and committed answer onto one render path #642.
- Design confirmed via a shaping pass and cross-checked by two independent code reviews (arm-from-
send(), timer placement, backgrounded-session self-heal, no-materialize injection). - Related: refactor(ui): unify streaming (live) and committed answer onto one render path #642 (single render path), refactor(ui): close out post-#520 renderer surface polish #546 (visual-polish umbrella), feat(ui): govern chat conversation-flow display onto @maka/ui Base UI primitives #332 (why tool styles live in TSX).
Activity
[-]打磨:各沟通环节的实时状态反馈(连接中 / 输出中 / 工具运行 seam)[/-][+]Polish: real-time status feedback across communication stages (connecting / streaming / tool run→done seam)[/+]Astro-Han commentedon Jul 8, 2026
Rewritten in English for the record; parking this rather than tracking it as an open issue for now — reopen when the real-time-feedback polish is picked up.
Astro-Han commentedon Jul 8, 2026
Reopening to track under the long-term visual polish umbrella #546. The tool run→done seam and running-state visibility land under #546 PR6 (tool rendering polish); the connecting/outputting status language is a cross-stage real-time-status concern in the same family.
[-]Polish: real-time status feedback across communication stages (connecting / streaming / tool run→done seam)[/-][+]Real-time status language across stages: connecting indicator + stable thinking/outputting signal[/+][-]Real-time status language across stages: connecting indicator + stable thinking/outputting signal[/-][+]feat(ui): real-time status language across stages — connecting indicator + stable thinking/outputting signal[/+]Astro-Han commentedon Jul 9, 2026
Field findings from live CDP measurement (GLM / glm-5.2)
Two symptoms reported on a real machine; measured on the running app over CDP
(instrumented the live event stream with timestamps + delta sizes, sampled the
rendered bubble's innerText length per frame, and polled session status + the
indicator DOM). Neither is a regression from the current branch — one lives in
the streaming/markdown render path, the other in this issue's own indicator
gating.
Symptom 1 — "output jumps a chunk at a time" is markdown block reflow, not a stream-smoothing snap
Measured a long, tool-free GLM answer (1945
text_deltas over ~34s):maxCps=400peaks at ~56 graphemes — nowhere near themaxBacklogGraphemes=800snap threshold. Zero snaps.innerTextjumped +797 in a single 46ms frame (2109 → 2906) at the moment the table separator row arrived — while the wire was still delivering 1–6 grapheme deltas and the smoother's displayed text was still trailing the raw text by ~377 graphemes (so no snap fired). The table's lines render as inline text until|---|completes, then re-parse into a grid all at once.Conclusion: the
maxBacklogGraphemes=800snap hypothesis does not explain the observed jumpiness for GLM — GLM streams tiny deltas and the snap never triggers. The "chunk then jump" is (a) incremental-markdown re-parse reflow when a block (table/heading/code fence) completes, plus (b) in multi-step turns, the natural per-step text + tool-gap cadence.Symptom 2 — the composer "正在处理…" indicator is intermittent (a status-gate race)
Across 4 measured turns, "正在处理…" showed 0/4 (the composer went straight to "正在回答…"), yet the user sees it sometimes — i.e. it's racy, not dead.
Mechanism:
armTurnActivesetsturnPhase='waiting'synchronously at send (app-shell-chat-actions.ts), but the existing-session send path does not optimistically set session status torunning(only the new-session path callsrefreshSessions).activeSession.statusonly flips torunningasynchronously viasubscribeChanges(app-shell-effects.ts) after the runtime persists the run (agent-run.ts).sessionAwaitingModel = activeSession?.status === 'running'(app-shell.tsx:372) and a 200ms reveal delay.runningand the 200ms delay both resolve before GLM's first token flipsturnPhasewaiting→streamed. With GLM first-token ~2s and the status update lagging behind the store (store status wasrunningat send+50–100ms in my captures, yet the indicator still never showed), the head window is usually missed.The
status === 'running'gate is redundant with the phase model:turnInFlight(phase present) already means "armed, awaiting first token". Gating additionally on an async-lagging status is what makes the head indicator flaky.Fix directions (not applied yet):
turnPhase === 'waiting'alone (drop thestatus === 'running'gate for the head), or set status optimistically at send. Minimal + removes the race.Method note
Captured via CDP on the running dev build: parallel
subscribeEventssubscription for wire timing/sizes, a.maka-bubble-streaminginnerText sampler for render smoothness, session-status polling, and an indicator-DOM poller. Raw per-run numbers available on request.fix(ui): drop the stray strokeWidth on the model-wait spinner (icon g…
fix(desktop): open the 正在处理 gate at send via optimistic running status (
fix(desktop): gate composer Stop on session status so a backgrounded …
fix(desktop): heal backgrounded transient state and roll back optimis…
fix(desktop): reconcile stale live transient at session switch-in to …
fix(desktop): drive stale-transient healing off authoritative status,…
fix(desktop): narrow the settled-session transient heal to turn state…
feat(ui): real-time status language — first-token model-wait indicato…
Astro-Han commentedon Jul 11, 2026
Verified against current main. The intermittent processing-indicator race was fixed by #680: send now marks the session as running optimistically before the IPC status round-trip, with rollback on failed sends. The desktop suite passes with 2315 tests and no failures. Closing as completed. The separately observed markdown block-reflow jumpiness remains out of scope and should be tracked independently.
refactor(frontend): simplification batch 2 — mounted-guard sweep + ap…
refactor(desktop): extract ChatComposerRegion from the chat surface
refactor(desktop): extract the chat surface from app-shell (#1169)
feat(enterprise): conversation core batch 1 — wait cues, stale turns,…
fix(enterprise): running status lives in the transcript, with the ela…