feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in - #636
Merged
Merged
Conversation
Add optional ToolCallMessage.stepId, stamped by ToolRuntime from getCurrentStepId() (same source as ToolStartEvent.stepId), and map it into refs.stepId on the backfill path so post-restart model replay re-pairs a tool call with its assistant step. The field ships with its first consumer, the UI turn timeline (materializeTurns, next commit).
Replace PR1's neutral multi-step concatenation with a TurnTimelineItem sequence (thinking / text / tools) rebuilt in storage order: each step's thinking and text precede the tools whose stepId matches that step; legacy stepless tools keep the tools-then-summary reading before the text; leftover and live-only tools flush as trailing tool groups; adjacent thinking and tool groups merge. Aggregate assistant/assistantThinking stay for copy/export/ prompt-rail consumers, with timeline as the rendering source of truth. ToolActivityItem gains stepId for the pairing. Tests cover interleaved multi-step, legacy single, pure-tool tail, live-only append, and merges.
…ol trow Render the turn body from turn.timeline (materialize.ts) instead of the tools-section + single-answer layout: each step's reasoning, answer, and tools appear in production order. Commits 2 and 3 of the plan land together because both restructure TurnView and can't be judged apart. - DeepThinking: one controlled Collapsible (collapsed by default; no defaultOpen) replacing ReasoningPanel and the .maka-turn-thinking <details>. Live: shimmering '深度思考' title (new TextShimmer primitive + governance keyframe maka-text-shimmer) and smooth plain-text body that follows the tail; settled: Markdown + '复制思考过程'. '已截断' pill preserved. - ToolTrow: a contiguous tool run as one flat, borderless disclosure — single-tool groups render the tool's own row (no double nesting); multi-tool groups add a summary line (shimmering active-tool description while running, bucketed Chinese counts once settled via the pure summarizeTrowTools) that expands to flat-stacked tool rows. waiting_permission auto-expands the group. ToolActivityCard body extracted as ToolCardBody, shared by card and trow. - Delete reasoning-panel.css + import, the .maka-turn-thinking / .maka-turn-tools token blocks; update chat-marker + 406-motion governance contracts. Full desktop suite (2224) + root typecheck green; trow-summary unit-tested.
Retire the ▎ blink caret (.maka-bubble-streaming::after + @Keyframes maka-cursor) and its trailing-<p> display:inline hack in favor of a per-word fade-in over the freshly revealed streaming tail. New pure primitive stream-fade.ts: an append-record ring tracks each growth of the displayed grapheme prefix as a batch, prunes batches older than the fade window, and caps retained batches. From it we read a stable boundary offset (older text never re-animates) and the age of any offset (drives a negative CSS animation-delay so the entrance resumes mid-flight across ~60Hz re-renders instead of re-flashing). A shared word/char tokenizer groups whitespace and Latin runs while splitting CJK/emoji per-grapheme. streamFadeRehypePlugin wraps post-boundary tokens in .maka-stream-fade spans after rehype-highlight, advancing the cursor through code fences but never wrapping inside them. useStreamFade feeds both the answer bubble (via markdown-body's rehype pass) and DeepThinking's plain-text live body. Under snap (reduced-motion / visual-smoke) the hook returns undefined and callers skip wrapping, so deterministic captures show final text at full opacity. @Keyframes maka-stream-fade-in animates opacity only; the fade window is --duration-large (280ms) and FADE_MS is pinned to the same value so the JS and CSS windows agree. Contracts updated: 406 governance functional motion set, markdown-prose retired-hack asserts. Unit tests cover the ring (window slide, order-independent ages, cap), the tokenizer, and the rehype offset bookkeeping (multi-block prose, code fences unwrapped).
Finish the streaming UI rework wiring so the in-flight (live) path and the review fixtures exercise the new per-step turn timeline. - app-shell-session-events: the tool_start handler now carries the event's stepId onto the live ToolActivityItem, so an in-flight tool is ordered after its own step's thinking/text in the turn timeline instead of being lumped into one trailing group. Optional — legacy / degraded history emits no stepId and keeps the old grouping. - chat-surface stories: add a MultiStepReasoning story whose fixture is a single turn with two think->say->call steps (per-step thinking + text + stepId-tagged tools), so Storybook covers the 深度思考 → answer → tool trow per-step order. - visual-smoke turn-narrative seed: append a second, multi-step turn to the committed narrative so the auto-capture baseline locks the new per-step timeline alongside the existing legacy stepless turn. Verified: @maka/ui build + test, desktop build:main + full node:test suite (2237), @maka/runtime test, and root typecheck (incl. renderer + storybook projects) all green.
projectFunctionCall dropped event.refs.stepId, so sessions rebuilt from the runtime event log (the session view's source of truth) lost the tool-to-step pairing after reload and the UI timeline fell back to legacy tools-before-text ordering. Carry it through and lock with a projection test covering both the stepId and legacy no-stepId shapes. (Codex review P1.)
The fade ring measures raw buffer graphemes but the rehype pass walks rendered visible text, which link URLs and emphasis markers leave far shorter; once the cumulative hidden amount outgrew the 280ms window the streaming tail silently stopped fading. Count the visible graphemes, shift the boundary and age lookups by the hidden total (exact at the tail, eager near the boundary), and lock with a link-syntax test that asserts raw-coordinate age lookups. (Codex + Pi review P2.)
kind+messageId is already unique across a turn's timeline (one thinking and one text per step; tool groups keyed by first toolUseId). Folding the index in meant a group inserted mid-timeline remounted every entry after it, collapsing disclosures the user had opened. (Codex review P3.)
… answer A step that only calls tools persists no assistant row, so its tools carry a stepId no assistant message matches; buildTurnTimeline parked them in pending until the end of the turn, rendering the common 'call tools, then summarize next step' pattern as answer-then-tools. Ledger append order guarantees no later step's tools can be pending at an assistant row, so flush the orphans ahead of the step's content. (ChatGPT review P1.)
The group only auto-opened for waiting_permission, so an errored tool's banner and output hid behind the collapsed summary line — a diagnosability regression from the boxed cards, which kept errored tools expanded. Extract the predicate as trowNeedsAttention and unit-test it. (ChatGPT review P3.)
Astro-Han
force-pushed
the
feat/streaming-ui-rework
branch
from
July 8, 2026 11:26
6c46168 to
34e379a
Compare
--font-mono leads with Geist Mono Variable, which ships programming ligatures (===, ->, !=) on by default. In tool output a run like `=== 目录 ===` collapsed into a strikethrough-looking rule (the reported "删除线" bug). Nothing set font-variant-ligatures anywhere. Turn ligatures off on the code/terminal/stream/diff/preview mono surfaces (streamVariants.body, previewVariants overlay/terminal-*/ diff-body/office-stream) and on the shared .maka-code base (which also covers the permission-dialog command line). Code and commands read literally, so ligatures never belong here.
Two coupled fixes to the streaming-UI tool trow. Detail body (ToolCardBody): drop the raw JSON args box entirely. Shell tools now show a single `$ <command>` line while in flight (the settled terminal preview already prints the command); other tools keep a compact redacted-args view. The body no longer double-prints stdout as both a live stream and a result preview — the live stream shows only while running, the result preview is the single quiet output block once settled. The whole area is a flat, left-border-indented block (no nested card frame), one language with the 深度思考 disclosure body. Args/intent still route through formatRedactedJson / formatToolIntent (tool-args-redaction-contract). The permission-wait / error banner and trowNeedsAttention forced-open behavior are untouched. Row language: the single-tool row now matches the multi-tool summary row — a kind icon + a user-language phrase (intent, falling back to the friendly name), not the old status-dot + mono tool-name + status word. Running shimmers via TextShimmer, errored tints destructive (both rows), and the duration + chevron ride in on hover / open as quiet meta.
Settled 深度思考 body: render the reasoning as plain caption-tier text (muted, regular weight, no italic) instead of a prose-Markdown block, so it never jumps size against the live streaming body and long reasoning stops reading as italic. The "复制思考过程" action was a size="icon-sm" button with a text label, which squeezed the label into a vertical per-character stack. It is now an icon-only hover affordance (footerStyle, full aria-label kept) pinned to the top-right of the indented block, so it never crowds the reading column. Timeline rhythm + parity: drop the per-item my-0.5 on the DeepThinking disclosure so the timeline's flex gap is the single spacing rhythm, and wrap the streaming section's thinking + answer in the same flex-col gap-2 container a committed turn uses, so the live and committed 深度思考 sites are structurally identical.
The folded chat timeline jittered between font sizes and weights: the 深度思考 trigger rendered at an accidental 13px/500 (its `text-[length:var(--font-size-caption)]` was defeated by the Collapsible trigger base `[font:inherit]`, and its label carried `font-weight-medium`), while tool trow rows rendered at 11px/400. Adjacent rows switching size, weight, and text start read as visual noise. Collapse every meta row (深度思考 trigger, single tool row, tool group row, running TextShimmer) onto one tier — base 13px / weight 400 / muted-foreground — and let color alone carry hierarchy against the foreground answer body, matching Claude.ai / ChatGPT / Codex. The 深度思考 row is now structurally identical to a tool row: a 16px Brain icon slot, a base-size muted label, and a hover-reveal trailing chevron, so icons and labels share one x. Caption (11px) is kept only inside expanded detail bodies (reasoning text, terminal, args, hover duration). The dead caption class on the 深度思考 trigger is removed. CDP (9230) verified: 深度思考 / single tool / tool group rows all render 13px / 400 / oklch(0.585 0.0025 323), icon left 513, label left 537 — identical to each other and differing from the 13px answer body only in color.
…the wall clock
Live streaming with a thinking model rendered a BLANK assistant bubble
for the whole stream, then snapped the entire answer in at stream end.
CDP-instrumented runs (rAF-loop DOM probe + event-stream tap on
sessions:event IPC) isolated the mechanism:
- Provider deltas reach the renderer in bursts (dozens of IPC events
back-to-back). Each burst's commit storm overruns frame deadlines, so
Chromium's vsync-aligned rAF timestamps fall behind the wall clock.
- useSmoothStreamContent computes dtMs = (rAF timestamp) - (wall clock
at effect re-arm). During and right after a burst dtMs clamps to <= 0.
- computeFrameAdvance returned 0 for dtMs <= 0, and by design an
advance of 0 changes no state, so the RAF effect (keyed on
displayedCount/rawLength) never re-armed: the single-owner RAF chain
died holding a full backlog. Nothing woke it until the next delta,
which died the same way. Result: displayed stayed empty ('' or a
frozen 2-char prefix with a frozen fade animation-delay) while
hundreds of graphemes queued, and text_complete snapped it all in.
Fix: drop the dtMs <= 0 early-return so a tick with backlog always
advances at least 1 grapheme (the documented 'always advances at least
1 when there is work to do' contract, which the guard contradicted).
The chain re-arms off the state change and resumes EMA-paced speed once
timestamps normalize.
Verified live over CDP against a real provider run: text now grows
every frame (per-frame advance 1-3 graphemes, no >600ms stalls, fade
spans continuously active), the completion drain plays out grapheme by
grapheme, and the settled turn takes over with no end-of-stream snap.
…wer stops jumping The seam where the live streaming section swaps for the committed turn moved the whole conversation column in one frame. Root cause is structural: a live answer and a settled answer are two different DOM subtrees, and every height difference between them lands as a visible jump when one unmounts and the other mounts. Four such differences, all closed here so the swap is position- and height-neutral (opacity-only motion, no layout shift): - Status-badge cluster (.maka-chat-status-cluster) was conditionally unmounted when a run completed, snapping the column up by the badge-row height. It is now always mounted and collapses via a CSS `:empty` height transition (interpolate-size: allow-keywords). - The committed turn mounts a footer toolbar the live section lacks. The live section now reserves an equal-height placeholder box, so the real footer only fades in (governed `@keyframes maka-footer-fade-in`, opacity-only, from-only so it lands on the footer's quiet 0.72 opacity) instead of adding a row. The entrance fires only when the footer appears on an already-mounted turn (a live settle), never on history hydration. - .maka-turn-streaming now carries the same within-turn gap (var(--space-3)) the committed answer sits at, so the text renders at its final y. - The committed-history fallback settle now waits a 1000ms grace past the smoother's 600ms completion drain budget instead of settling immediately, so onStreamingSettled (fires when the tail is fully displayed) stays the primary handoff signal and the visible tail is never cut mid-typewriter. Contract tests updated: chat-status-cluster :empty transition, delayed fallback settle, and the maka-footer-fade-in governance allowlist entry. Reduced-motion / visual-smoke globals collapse all three durations.
Three-way review (ChatGPT P2) found the settle jump 0c947ee removed still fired for thinking-only / textless turns. A settled turn ALWAYS mounts a footer — deriveTurnFooterActions yields regenerate/branch from TurnStatus alone, independent of answer text, and materialize emits a timeline item for a step's thinking even with empty text — so a turn that only thought (think → tool → end, or aborted mid-think) settles WITH a footer. The live footer placeholder was gated on props.streamingText, leaving that shape unreserved, so its footer added a row at settle and re-anchored the bottom-pinned scroll. The placeholder already lives inside the `streamingText || thinkingText` section, so render it unconditionally there — it now covers every live turn. Contract test locks it: placeholder present unconditionally, never re-narrowed to streamingText.
…tless handoff in #642 Codex re-review of bd9b22c showed the unconditional footer placeholder is necessary but not sufficient for thinking-only / textless turns. Text turns settle via the draining→settleAssistantStreaming handshake (refresh the committed message before clearing the live slot), so the swap is atomic and the reserved box bridges it. Textless completion (drainAssistantStreaming `!applied.text`, plus abort-mid-think) clears the live section first and refreshes async, so the swap is non-atomic and can still flash. No behavior change — the placeholder is correct groundwork. This only corrects the code comment and contract-test wording to stop claiming it fully fixes the textless case, and points to the single-render-path convergence (#642), whose acceptance now covers the textless/thinking-only atomic swap.
# Conflicts: # packages/ui/src/tool-activity.tsx
Member
|
质量很高的 PR——三层核心(stepId 数据模型、时间线聚合、stream-fade)第一性原理审查全部通过,治理意识(动画白名单、token 绑定)也到位。已 rebase 到最新 main 并以 #644 收编合入(与 #639 的 CronJob 预览在 tool-activity.tsx 的冲突已解决:你的 ToolCardBody 抽取结构为准,cron 预览挂进共享 body)。本 PR 关闭。 一个非阻塞注记供后续参考:流式期间 useStreamFade + countVisibleGraphemes 每帧对全文做 grapheme 分词——与 react-markdown 每帧全量 parse 同级,不构成新的复杂度回归;如果将来做流式渲染增量化,这两处可以一起收益。 |
This was referenced Jul 9, 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
Rework the streaming output UI on top of step-level event logging (#633): materialize each turn as a render timeline (thinking → text → tools per step), replace both legacy thinking blocks with one collapsed-by-default 深度思考 disclosure (shimmer title while live), aggregate contiguous tool calls into a flat Codex-style trow with a bucketed Chinese summary, and replace the ▎ streaming caret with a per-word fade-in (append-ring + negative animation-delay, code fences exempt, snap paths off).
Why
The old UI rendered a turn as one tools box + one answer blob, hiding the model's actual think→act→say sequence that #633 now records faithfully. The caret was a synthetic "still streaming" signal; the fade-in makes the text itself carry it.
Scope
Changed:
ToolCallMessage.stepIdpersistence plus read-model/backfill projection;materialize.tsturn timeline;chat-view.tsxDeepThinking and timeline rendering;tool-activity.tsxtrow plustrow-summary.ts;stream-fade.tsplus rehype plugin; tokens/styles cleanup (reasoning-panel.cssdeleted); stories and visual-smoke fixtures.Not included: send() state machine and replay assembly (settled in #633); a new governance duration token for the fade (pinned to the existing
--duration-large, 280ms — see Reviewer notes).Verification
Root typecheck;
@maka/ui46 pass;@maka/runtime977+ pass; desktop 2237+ pass (including new stream-fade / trow-summary / materialize-turns contract and unit tests); Playwright e2e 5/5; check-dead-css clean. External review: Codex (xhigh) and Pi (deepseek-v4-pro); their P1 (read-model projection dropped stepId), P2 (fade coordinate mismatch across markdown-hidden syntax), and P3 (index-based timeline keys) are fixed with regression tests in the last three commits; remaining findings rejected with evidence.User-facing impact
New streaming visuals: thinking collapses to a 深度思考 disclosure, tool calls aggregate into an expandable one-line summary, answer text fades in per word. All animations are disabled under reduced-motion and visual-smoke.
Reviewer notes
The spec said fade ≈350ms, but the
motion-token-convergecontract bans bare ms durations, so the fade is pinned to--duration-large(280ms) with JS and CSS windows aligned to the same token. Manual Electron visual pass in progress; screenshots/video to follow.