feat(ui): real-time status language — first-token model-wait indicator, decoupled Stop, 继续中 hint (#646) - #680
Merged
Merged
Conversation
…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.
#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.
5 tasks
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.
This was referenced Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
turnActiveBySessionis nowRecord<string, 'waiting' | 'streamed'>(absent = no turn in flight).armTurnActivesets'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.MODEL_PROCESSING_DELAY_MS=200(prominent "正在处理…", first-token only) andMODEL_CONTINUING_DELAY_MS=600(calm "继续中…").streamingprop is driven offturnInFlight, so Stop persists through inter-step gaps.send()nudges the session status to'running'optimistically (both new- and existing-session paths) so thestatus === 'running'gate opens synchronously;subscribeChangesreconciles the real value.Composer.streamingis now(sessionAwaitingModel && turnInFlight) || activeStreamingLive. The SessionEvent stream only followsactiveId, 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.activeStreamingLiveslot (plus arm / live thinking / tools) mid-turn when its turn ends while backgrounded, so the ungatedactiveStreamingLivedisjunct would keep a stuck Stop and half-streamed bubble on return. Healing is keyed off the authoritative status landing insessions(useSettledSessionTransientReconcile, deps[sessions]), not off asessions:changedevent 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 longerrunning/waiting_for_userhas 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.clearTurnTransientState: streaming slot, live thinking + its truncated flag, live tools, the turn arm), not the fullclearSessionUiState. 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.text_complete-empty and thecompleteheldTextless branch) now marksettlingBySessionReffor the hold's duration; the reconcile skips those sessions.send()that fails before the runtime got nosubscribeChangesevent to reconcile the optimistic'running', leaving a phantom running dot and a blocked permission-mode toggle.markSessionRunningOptimisticnow returns a restore callback that the sendcatchinvokes (only while the status is still the optimistic'running').main(was 20 commits behind); dropped a straystrokeWidthprop 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:
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.jsonandtsc -p tsconfig.renderer.json— clean.User-facing impact
Reviewer notes
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).activeStreamingLivegaps were surfaced across successive ChatGPT reviews of this PR and verified against the code (activeId-only event subscription with no missed-event replay;subscribeChangesheals onlystatus, 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-gatedComposer.streamingform and the status-driven transient-heal (and forbid regressing to the event/switch triggers).main.Checklist