Skip to content

feat(ui): real-time status language — first-token model-wait indicator, decoupled Stop, 继续中 hint (#646) - #680

Merged
Astro-Han merged 16 commits into
mainfrom
feat/646-realtime-status-language
Jul 9, 2026
Merged

feat(ui): real-time status language — first-token model-wait indicator, decoupled Stop, 继续中 hint (#646)#680
Astro-Han merged 16 commits into
mainfrom
feat/646-realtime-status-language

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Real-time status language for the chat turn: a first-token "正在处理…" wait indicator, a calmer "继续中…" hint for mid-turn step-to-step lulls, and a Stop button that stays available for the whole turn — without the old "正在处理" flooding every inter-step gap. The head indicator now opens reliably at send instead of losing a race to the model's first token.

Why

Refs #646

On a real multi-step GLM turn the prominent indicator re-fired in every step-to-step gap, producing an endless "正在处理" flood, while Stop was coupled to that indicator and disappeared mid-turn. Separately, the head "正在处理…" indicator was intermittent — measured over CDP it lost the first-token race in 4/4 turns and never showed, because it was gated on a session status that updates asynchronously after the turn is already armed.

Scope

Changed:

  • Turn-phase model: turnActiveBySession is now Record<string, 'waiting' | 'streamed'> (absent = no turn in flight). armTurnActive sets 'waiting' at send; the first content event promotes it to 'streamed'. Illegal states (no turn ⇒ no phase) are unrepresentable.
  • deriveModelWait(...) → 'none' | 'processing' | 'continuing': 'processing' only in the 'waiting' head, 'continuing' for a post-content lull, 'none' while anything streams.
  • Two rising-edge delayed flags: MODEL_PROCESSING_DELAY_MS=200 (prominent "正在处理…", first-token only) and MODEL_CONTINUING_DELAY_MS=600 (calm "继续中…").
  • Stop decoupled from the wait indicator — the composer's streaming prop is driven off turnInFlight, so Stop persists through inter-step gaps.
  • Head-indicator race fix: send() nudges the session status to 'running' optimistically (both new- and existing-session paths) so the status === 'running' gate opens synchronously; subscribeChanges reconciles the real value.
  • Stop stale-arm fix (from ChatGPT review): Composer.streaming is now (sessionAwaitingModel && turnInFlight) || activeStreamingLive. The SessionEvent stream only follows activeId, so a session whose turn completes while backgrounded keeps its arm; gating on status means returning to it shows Send, not a stuck Stop. Composes with the optimistic-status change so Stop still covers the whole active turn.
  • Stale-transient heal, status-driven (from ChatGPT re-review): the same activeId-only subscription (no missed-event replay) freezes a session's activeStreamingLive slot (plus arm / live thinking / tools) mid-turn when its turn ends while backgrounded, so the ungated activeStreamingLive disjunct would keep a stuck Stop and half-streamed bubble on return. Healing is keyed off the authoritative status landing in sessions (useSettledSessionTransientReconcile, deps [sessions]), not off a sessions:changed event or an activeId switch (earlier rounds tried both; each read the status before the terminal refresh resolved, leaving a race). Whenever the sessions list settles, any session no longer running / waiting_for_user has its transient dropped; a slot mid-draining (a normal completion settling) is left to its own lifecycle. The Composer status gate becomes belt-and-suspenders.
  • Transient-heal scope narrowed (from ChatGPT re-review): the reconcile now drops only the turn transient (clearTurnTransientState: streaming slot, live thinking + its truncated flag, live tools, the turn arm), not the full clearSessionUiState. The full clear also wiped independently-scoped state — message-load-error / retry, pending permission-mode + model toggles, the permission queue, event-stream health — so any unrelated sessions refresh could nuke a terminal session's error banner while its messages never reloaded. That state now survives a mere settle; the full clear stays for session deletion.
  • Textless refresh-before-clear no longer raced by the heal (same re-review): a textless / thinking-only completion holds its live thinking mounted until the committed message refreshes in (refactor(ui): unify streaming (live) and committed answer onto one render path #642), but sets no draining slot for the reconcile to skip on — so a sessions settle landing before that refresh could clear the held thinking early, re-opening the refactor(ui): unify streaming (live) and committed answer onto one render path #642 unmount flicker. Both textless paths (text_complete-empty and the complete heldTextless branch) now mark settlingBySessionRef for the hold's duration; the reconcile skips those sessions.
  • Optimistic-status rollback (from ChatGPT re-review): a send() that fails before the runtime got no subscribeChanges event to reconcile the optimistic 'running', leaving a phantom running dot and a blocked permission-mode toggle. markSessionRunningOptimistic now returns a restore callback that the send catch invokes (only while the status is still the optimistic 'running').
  • Merge of latest main (was 20 commits behind); dropped a stray strokeWidth prop on the model-wait spinner to satisfy feat(ui): icon semantic remap + stroke unification + governance contract #662's icon-governance contract that arrived with the merge.

Not included:

  • The streaming "jumpiness" reported alongside this work is markdown block reflow (a table/heading/code fence re-parsing into shape), not a stream-smoothing bug — see the field-findings comment. It is a separate concern to be tracked in its own issue, not fixed here.

Verification

  • npm run -w @maka/desktop test — 2329 pass, 0 fail (includes the icon-governance contract, the send-optimistic-status regression test, the failed-send status-rollback test, the status-driven transient-heal source contract, the narrow turn-transient-clear regression that keeps message-load-error / pending toggles across a settle, and the textless-settling-mark regression).
  • npm run -w @maka/ui test — 46 pass, 0 fail.
  • tsc -p tsconfig.main.json and tsc -p tsconfig.renderer.json — clean.
  • Live CDP measurement against GLM on the running build: wire delta sizes, rendered-bubble smoothness, session-status timeline, and indicator DOM state; results in the issue comment linked above.

User-facing impact

  • "正在处理…" now appears reliably at the start of a turn's first-token wait, and no longer floods every step gap.
  • A quiet "继续中…" hint fills mid-turn lulls between steps.
  • Stop stays available for the entire turn, including inter-step gaps; returning to a session whose turn finished off-screen shows Send, not a stale Stop.
  • No schema, migration, or config changes.

Reviewer notes

  • The status === 'running' gate is intentionally kept (it self-heals a backgrounded session whose terminal event was missed); the fix corrects its lagging input rather than removing the gate. A failed send now disarms the turn AND synchronously restores the pre-optimistic status (no phantom running dot, no round-trip).
  • The stale-arm and stale-activeStreamingLive gaps were surfaced across successive ChatGPT reviews of this PR and verified against the code (activeId-only event subscription with no missed-event replay; subscribeChanges heals only status, never the transient slot). Two earlier attempts (event-triggered and switch-triggered reconciles) each read status before the terminal refresh resolved; the shipped heal is status-driven (useSettledSessionTransientReconcile). Source contracts lock both the status-gated Composer.streaming form and the status-driven transient-heal (and forbid regressing to the event/switch triggers).
  • Squash-merge collapses the branch (merge commit + fix commits) into one; the merge is only there to integrate latest main.

Checklist

  • Scope matches the PR title and excludes unrelated changes
  • Verification lists commands/results, or explains why they were not run
  • User-facing impact, docs, changelog, migrations, and breaking changes are noted, or marked none
  • Risk, rollback, or review focus is called out for non-trivial changes
  • UI changes include screenshots/video, or explain why not applicable — behavior verified via CDP measurement (numbers in the linked issue comment) and a screen recording shared in review; the change is a timing/copy fix with no new static layout to screenshot.

Astro-Han added 15 commits July 9, 2026 16:31
…ssing indicator (#646)

Pure logic layer for the '正在处理…' real-time-status indicator: a composite
predicate (turn active with nothing streaming — covers the turn head and the
mid-turn resume gap after a tool settles, no explicit re-arm) plus a
rising-edge delayed flag so a fast first token never flashes the indicator.
Scheduler is injected so the 200ms timing is unit-tested with fake timers
rather than a wall-clock wait.
Per-session boolean, armed at send() and cleared on turn end, plus a ref for
synchronous reads in the event wiring. Registered in SESSION_UI_MAP_KEYS so it
inherits per-session teardown (compile-time exhaustiveness enforces it); the
clear-every-map test now covers it.
…-end events (#646)

send() arms turnActiveBySession the moment it commits (before the IPC
round-trip) so the model-wait window covers the connect-to-first-token gap,
which has no SessionEvent of its own; a failed send disarms it. The turn-end
events (complete for a non-permission_handoff stop, error, abort) clear it
synchronously — mirroring the unguarded clearStreaming, since a turn-ending
event runs before the next turn is armed. Content events don't touch the flag;
the derivation hides the indicator while anything streams, so the flag stays
armed across the whole turn and the mid-turn resume gap recovers on its own.
Drops the unused ref added for a turnId guard that isn't needed. Wiring covered
in streaming-handoff.
)

Wire the debounced turn-active derivation into ChatView: app-shell computes
showProcessingIndicator (status running + deriveModelWaitIdle, 200ms rising-edge
delay via the new useDelayedFlag hook) and passes it to ChatView, which injects a
transient ModelProcessingIndicator (neutral spinner + TextShimmer) as the tail
turn's trailing live entry — covering the connect-to-first-token gap and the
resume gap after a tool settles. Render contract + arm/clear wiring tests.
…one seam (#646)

A running tool row is visible instantly but its working shimmer only sweeps after
~200ms (a pure-CSS animation-delay on the TextShimmer sweep, new `delayed` prop),
so a sub-second tool never flickers. On settle, the row plays a one-shot fade
landing — reusing the whitelisted maka-stream-fade-in keyframe, no new keyframe —
gated by a sticky everRunning ref so replayed transcript rows stay static. Adds a
data-settled attribute for the visual-smoke endpoint. Pure status→motion mapping
(deriveToolRowMotion) is unit-tested; render seam locked in streaming-handoff.
…moke scenario (#646)

Fold the (debounced, status-gated) processing indicator into the composer
`streaming` prop so Stop is available while the model is being awaited with
nothing streaming yet — the moment a user most wants to interrupt. Both disjuncts
are draining-safe, so a draining answer still settles the composer (contract
updated). Adds a `model-processing` visual-smoke scenario (turnActiveBySession on
VisualSmokeState + hydration + a running session seeded with a lone user turn) so
the "正在处理…" indicator + Stop state has a deterministic capture; fixture test
locks the seed. PNG baselines + 200ms feel-tuning are runtime capture steps.
…#646)

Real-window capture surfaced a copy mismatch: with Stop folded into the wait
window, the composer showed "Maka 正在回答…" while the timeline showed "正在处理…"
for the same pre-first-token moment. Add a `processing` prop so the composer hint
matches the timeline indicator ("Maka 正在处理…" / "Maka is working…") before the
first token, reverting to the responding copy once real output streams. SSR
render test covers both phases.
… not every step gap (#646)

The "正在处理…" indicator was firing in every step-to-step lull of a
multi-step agentic turn, not just the connect-to-first-token wait it was
designed for. Real machine repro (CDP over a 4-step GLM turn): the model
thinks → answers → calls a tool → and each hop back to the provider has
its own multi-second first-token latency; the indicator re-satisfied in
every one of those gaps, so "正在处理" flooded the turn and the brief
per-step reasoning read as swallowed.

Root cause: the wait predicate looked only at instantaneous stream state
(nothing streaming right now), which is equally true at the turn head and
in mid-turn lulls. The missing dimension is whether the turn has produced
any content yet.

Encode that as a turn PHASE instead of a boolean: turnActiveBySession now
holds 'waiting' (armed at send, no content yet) or 'streamed' (promoted on
the first content event, guarded one-way). deriveModelWait returns
'processing' only in 'waiting', 'continuing' for a mid-turn lull, 'none'
otherwise. Making illegal states unrepresentable (no turn ⇒ no phase)
removes the parallel-flag desync class a second boolean would add.

UX split, per product decision:
- first-token head keeps the prominent "正在处理…" indicator + shimmer;
- mid-turn lulls get a calm, dimmed "继续中…" hint (no spinner/shimmer,
  600ms delay so quick step hops don't flash it);
- Stop is decoupled from the indicators and driven off turnInFlight, so it
  stays available for the WHOLE turn (including the long inter-step waits)
  instead of blinking out whenever nothing streams.

Adds a multi-step arm/phase regression to streaming-handoff (the gap the
lone-user-turn visual-smoke fixture missed) and reverses the model-wait
test that asserted the old mid-turn re-trigger. Verified live over CDP:
Stop present the whole turn, "正在处理" never re-appears, "继续中" shows in
each inter-step gap.
…overnance) (#646)

Merging main brought in #662's icon governance contract, which forbids
per-call-site strokeWidth props. The Loader2 spinner in ModelProcessingIndicator
still carried one; ride lucide's governed default stroke instead.
#646)

The head model-wait indicator is gated on activeSession.status === 'running'
(that gate self-heals a backgrounded session whose terminal event was missed).
But the existing-session send path armed the turn phase synchronously while the
status only flipped to 'running' asynchronously, after the runtime persisted the
run and subscribeChanges echoed it back. Measured over CDP against GLM: the
first-token wait lost that race in 4/4 turns, so "正在处理…" never showed even
though the wait was multi-second.

Nudge the session's status to 'running' optimistically the moment send() commits,
for both the new- and existing-session paths, so the gate opens without waiting
for the round-trip. subscribeChanges reconciles the real value; a failed send
disarms the turn (no stuck indicator) and the status self-corrects on the next
refresh. Regression test asserts send() marks the session running and arms the
'waiting' phase synchronously.
…turn's stale arm can't hide Send (#646)

ChatGPT review of #680 caught a real regression introduced by decoupling Stop to
turnInFlight: the SessionEvent stream only follows activeId, so a session whose
turn completes while backgrounded never receives its terminal event and keeps its
turnActiveBySession arm. On return, the status-gated wait indicators self-heal
(sessions:changed keeps status truthful) but Composer.streaming was driven by the
bare arm, so Stop stayed up and Send was hidden — the user could not send the next
message.

Gate the arm on sessionAwaitingModel (status === 'running'): Composer.streaming is
now (sessionAwaitingModel && turnInFlight) || activeStreamingLive. This composes
with markSessionRunningOptimistic (status is running synchronously at send, so Stop
still shows for the whole active turn including step gaps) while a backgrounded
completed session — status back to a terminal value — correctly shows Send. Source
contract updated to lock the status-gated form against a regression to bare
turnInFlight.
…tic status on failed send (#646)

Two stale-state gaps surfaced by the PR #680 re-review:

- A backgrounded session's SessionEvent stream isn't subscribed (activeId
  only), so a turn that ends off-screen froze its arm + streaming slot +
  live tools mid-turn — returning showed a stuck Stop via the ungated
  activeStreamingLive disjunct. Reconcile transient renderer state against
  the authoritative status in sessions:changed: once a non-active session
  is no longer running/waiting_for_user, drop its transient state. The
  Composer status gate then becomes belt-and-suspenders.
- send() nudges status to 'running' optimistically; a send that fails
  before the runtime got no subscribeChanges event to reconcile it, leaving
  a phantom running dot and a blocked permission-mode toggle. Return a
  restore callback from markSessionRunningOptimistic and invoke it in the
  send catch (only while still the optimistic 'running').

Adds regression tests for both; a source contract locks the reconcile.
…close the terminal-reconcile race (#646)

handleSessionChange's terminal reconcile keeps an active-guard (so a live
completion's draining lifecycle isn't cut short), but that guard also skips a
backgrounded-completed session the user switches back into before its
refreshSessions() resolves — leaving a frozen streaming slot / arm that keeps a
stuck Stop via the ungated activeStreamingLive. Reconcile again at switch-in
(useActiveSessionEvents, activeId-change only): a settled session
(not running / waiting_for_user) with a stale streaming slot (never drained)
gets its transient UI state dropped before the live stream is re-established.
Draining slots and same-session live completions are untouched (fires only on
switch), so no happy-path flicker or fresh-send wipe.

Found by ChatGPT re-review of #680. A source contract locks the switch-in reconcile.
… not events/switches (#646)

Third ChatGPT re-review round found the prior two reconciles (sessions:changed
event and switch-in) both read status too early: a session that completes while
backgrounded has its terminal status land in `sessions` at a moment neither
trigger covers (the switch-in reconcile reads a not-yet-refreshed sessionsRef;
handleSessionChange's .then bails on the active-guard once the user has switched
back), and subscribeEvents has no replay — so the frozen streaming slot keeps a
stuck Stop via the ungated activeStreamingLive.

Root cause: transient live state is only maintained by the active session's event
stream, but the sole authority on whether a turn ended is the status. Healing was
hung off proxy triggers (events, switches) that can fire before the authoritative
status lands. Replace both with a single status-driven reconcile
(useSettledSessionTransientReconcile, deps [sessions]): whenever the sessions list
settles, drop the transient of any session no longer running/waiting_for_user,
leaving a draining slot to its own lifecycle. Keying off the status landing in
`sessions` closes the whole "read too early" class regardless of which path or
timing delivers it. Net less code; source contract locks the new form and forbids
the old event/switch triggers.
Astro-Han added a commit that referenced this pull request Jul 9, 2026
* fix(ci): resolve electron binary portably in alignment auditor

#695 made the alignment auditor a CI gate but left the macOS
Electron.app path hardcoded, so ubuntu-latest e2e dies with ENOENT
before any fixture runs (breaks main and every open PR, including
#680). Resolve via the electron package export like capture-screenshots
and real-window-smoke, and fold spawn errors into the per-fixture
failure count instead of an unhandled crash.

* fix(ci): harden alignment auditor electron launch for Linux CI

Portable electron path alone was not enough: every fixture still died
with "fetch failed" on ubuntu-latest because the auditor launched unlike
capture-screenshots / Playwright e2e (no cwd, switches after app path,
stdio ignored, no Linux sandbox flags, fixed 8.5s sleep).

Align spawn with the working launchers: cwd=apps/desktop, app='.',
Chromium switches first, --no-sandbox/--disable-gpu/--disable-dev-shm-usage
on Linux, pipe stderr/stdout, poll CDP until a page target appears, and
include process tails in fixture errors so the next failure is diagnosable.
… only (#646 review)

The status-driven reconcile reused the full clearSessionUiState, which also
wipes message-load-error / retry / pending permission-mode + model toggles /
permission queue / event-stream health — none of which belong to a turn. Any
unrelated sessions refresh would nuke a terminal session's error banner while
its messages never reloaded. Clear only the turn transient
(clearTurnTransientState) instead; the full clear stays for session deletion.

Also skip a session whose textless / thinking-only completion is mid
refresh-before-clear (#642): that path sets no draining slot for the reconcile
to key off, so the reconcile could clear the held live thinking before the
committed message lands, re-opening the #642 unmount flicker. Both textless
paths now mark settlingBySessionRef for the hold's duration; the reconcile
skips those sessions.
@Astro-Han
Astro-Han merged commit f0b948c into main Jul 9, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the feat/646-realtime-status-language branch July 9, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant