Skip to content

refactor(ui): unify streaming and settled answers onto a single render path (#642) - #647

Merged
jackwener merged 4 commits into
mainfrom
refactor/642-single-render-path
Jul 8, 2026
Merged

refactor(ui): unify streaming and settled answers onto a single render path (#642)#647
jackwener merged 4 commits into
mainfrom
refactor/642-single-render-path

Conversation

@Astro-Han

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

Copy link
Copy Markdown
Contributor

Closes #642

Why

A single assistant answer used to be rendered by two unrelated React subtrees: the settled turns.map(TurnView) and a separate live <section className="maka-turn maka-turn-streaming">. The stream-to-settled handoff worked by unmounting one subtree and mounting the other, and that seam forced a whole set of hand-tuned special cases (streaming-shell margin, footer placeholder, fade-in, always-mounted status cluster, a 1000ms grace window). Worse, the textless / thinking-only completion path cleared the live region before the settled turn mounted, leaving a non-atomic flicker.

The key opening: the user message commits synchronously the moment it is sent, so during streaming materializeTurns(messages) already emits the tail turn (user bubble + correct turnId + an empty assistant timeline), and the runtime assigns the assistant rows the same turnId. Injecting the live content into that already-present tail turn turns the handoff into a data-source swap on one stable key={turnId} node — no unmount.

What changed

  • Tail-turn injection: TurnView gains an optional liveStreaming prop; only the turn.turnId === tailTurnId TurnView receives a fresh object per delta (so only it re-renders), every sibling gets a stable undefined and skips via memo. The standalone maka-turn-streaming subtree and its alignment special-cases are deleted. The perf invariant holds: exactly one TurnView re-renders per frame, same as before.
  • Hover-revealed footer / user time: both answer footers and the user-message time default to fully transparent and fade in on hover of their own content block (group-hover/answer, group-hover/usermsg, with focus-within preserving a11y). Height is reserved throughout, so the handoff is height-neutral. The user-message time is now an absolute HH:mm.
  • content-visibility remap: the content-visibility: visible override moves from .maka-turn-streaming to .maka-turn[data-live-streaming="true"]; dead rules removed.
  • Atomic textless / thinking-only handoff: the textless branch of drainAssistantStreaming now refreshes committed history before clearing the live buffer (refresh-before-clear), holding the live answer until the committed message lands — no unmount gap.
  • Folded-timeline polish (from manual testing): the DeepThinking settled body gets the same max-h-64 scroll bound as the live <pre>, so an expanded long reasoning no longer jumps taller the frame it settles; the chevron on all three folded trows (deep-thinking, tool group, tool row) drops ml-auto so it sits next to the label text instead of the far edge.

Deferred (split into #646, does not block this PR)

Real-time status feedback across the communication stages: a "connecting" state while the LLM connects, an "outputting" state during streaming, and a clear running-to-done seam for tools. The tool shimmer is correctly wired and verified to animate, but it flashes by on sub-second tools and lacks an explicit running-to-done transition.

Verification

  • npm run typecheck clean; @maka/ui 46 + @maka/desktop 2257 tests pass (including the new refactor(ui): unify streaming (live) and committed answer onto one render path #642 behavior tests and a tool-shimmer render regression test).
  • CDP live check (clean build): turnId stays stable across the handoff, no maka-turn-streaming node, footer and user time reveal only on hover. Issue 1 (a long settled reasoning is capped at a 256px scroll box, no height jump) and issue 2 (chevron sits 8px after the label, not far-right) both confirmed live.

Astro-Han added 3 commits July 9, 2026 00:14
…642)

The in-flight answer no longer renders in a separate `.maka-turn-streaming`
section that gets unmounted and swapped for a committed `TurnView` at settle.
Instead the streaming buffer is injected into the tail turn's own TurnView (the
same `key={turnId}` node the committed turn settles into), so live→settled is a
data-source swap on a stable DOM node, not a two-subtree handoff.

- chat-view.tsx: `TurnView` takes an optional `liveStreaming` prop, passed only
  to the tail turn (`turns[last]` while streaming). The separate streaming
  section is deleted; a tiny fallback covers the rare "streaming before the
  optimistic user turn materialized" case. Per-frame work stays scoped to the
  tail turn — `materializeTurns` is still memoized on committed state only, so
  sibling TurnViews keep their identity and skip re-render.
- Footer is now hidden by default and revealed on hover / focus-within of the
  answer block (both the assistant turn footer and the user-message copy),
  replacing the quiet-0.72 + settle fade-in. While a turn is live its footer
  slot is a reserved-height placeholder, never an actionable regenerate/branch
  (a live tail's derived status is `completed`).
- maka-tokens.css: re-key the content-visibility override onto
  `.maka-turn[data-live-streaming="true"]`; drop the dead streaming box/margin
  rule and the `maka-footer-fade-in` keyframe.
- session-events: textless / thinking-only completion now refreshes the
  committed message in BEFORE clearing the live buffer, so the shared node's
  answer block never unmounts before the committed turn lands.

Tests: rewrite the footer-placeholder / marker-cascade / motion-allowlist
contracts for the single-node shape; add behavioral coverage for single-node
streaming, footer suppression while live, hover-gated settled footer, multi-step
and tail-selection, and textless refresh-before-clear.
…642)

The time under a user message was a self-refreshing relative label ("2小时前")
that stayed always-visible. Per design review it should read as absolute
wall-clock time and share the copy action's hover reveal.

- chat-display-helpers: add `formatClockTime` (24-hour `HH:mm`).
- chat-view: the user meta row now shows `HH:mm` (full date stays on the
  `title`) and the whole row — time + copy — is hover-gated on the user bubble
  (`group/usermsg`, opacity-0 → group-hover/focus-within), matching the
  assistant footer's hover reveal. Drops the now-unused `RelativeTime` import.
…ar label (#642)

Three manual-test findings on the folded streaming/settled chrome:

1. DeepThinking live→settled height jump: the live body is a bounded
   `max-h-64 overflow-y-auto` scroll box, but the settled body was
   unbounded, so a long reasoning expanded to a full vertical strip and
   jumped taller the frame it settled. Bound the settled body to the same
   256px scroll box so the live→settled swap is height-neutral.

2. Chevron pinned far-right: every folded trow (深度思考, tool group, tool
   row) pushed its chevron to the trigger's trailing edge via `ml-auto`.
   Drop `ml-auto` so the chevron (and the tool row's duration) sits right
   after the label text (gap-2), near the words instead of巨右.

3. Running-tool shimmer: verified the trow shimmer is correctly wired —
   a running/pending tool renders the same TextShimmer light-band the
   深度思考 title uses, and the animation runs live in-build. Locked it in
   with a render regression test (running + pending shimmer, completed does
   not).
@Astro-Han Astro-Han changed the title refactor(ui): 单一渲染路径 — 流式与定稿合并到同一个 turn 节点 (#642) refactor(ui): unify streaming and settled answers onto a single render path (#642) Jul 8, 2026
Two P2 regressions found by the #642 review (codex + pi + ChatGPT):

P2-A — deferred textless clear could wipe a newer turn. The #642
refresh-before-clear made the textless / thinking-only completion clear the
live buffer AFTER an async refresh resolves, but `clearStreaming` has no slot
identity guard (unlike the text path's `clearSettledAssistantStreamSlot`). If
the refresh resolved after the user started the next turn, the deferred clear
blanked turn-2's answer + reasoning. Snapshot the turn's identity (messageId +
thinking) at schedule time and clear only the parts still current, via a new
`clearStreamingIfCurrent`. Adds a synced `thinkingBySessionRef` so the snapshot
can read live thinking (which has no per-turn id in state).

P2-B — a tool-only step showed an actionable footer on a still-running answer.
`streamingActive` was derived from streamingText/thinkingText only, so a
`tool_start` with no answer text yet fell through to the settled branch, whose
derived status defaults to `completed` — rendering a clickable regenerate/branch
on an in-flight answer. Extend liveness to in-flight tools
(running/pending/waiting_permission); LiveStreamingEntries still no-ops on empty
text so the tool-only tail shows just its timeline tool, no empty bubble.

Regression tests: newer-turn slot + thinking survival and a positive control
for P2-A; footer suppression while a tool runs + settled-turn control for P2-B.
codex P2 (tail selection) was a false positive — pi verified the optimistic
user-message commit keeps the tail == in-flight invariant. Touch a11y left as
the user's deliberate hover-gating choice (focus-within fallback kept).

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough review passed — this is careful work. Verified the core claim (the two-subtree seam is genuinely gone: live answer renders as trailing timeline entries inside the tail turn's own TurnView, keyed by the stable turnId, so settle is a data-source swap on one node) and the three risk layers: no re-animation on settle (committed Markdown renders without streamFade; the visibleMessages filter closes the double-render window and the slot-clear releases it in a single state update), keying stays stable mid-stream (memo'd TurnView + referentially-stable undefined for non-tail turns), and materializeTurns deps keep pure-text streaming out of the hot path. Contract hygiene is exemplary: every touched contract re-pinned in its new form, +8 behavioral streaming-handoff tests, and the removed streaming CSS/keyframes have zero dangling references.

Two conscious sign-offs recorded: (1) the user-message time is now hover/focus-gated (was always-visible for touch/AT) — accepting as the deliberate design shift from 3c79b37; full timestamp remains on the bubble title. (2) The chevron moves + hover-gated footer opacity are #642 detail-audit changes riding along in a refactor PR — fine, but noted so visual-baseline reviewers expect them.

Verified on today's main: desktop suite 2258/2258 green, CDP visual smoke of streaming-answer + turn-narrative fixtures clean.

@jackwener
jackwener merged commit 05ba1bc into main Jul 8, 2026
3 checks passed
@jackwener
jackwener deleted the refactor/642-single-render-path branch July 8, 2026 17:56
jackwener added a commit that referenced this pull request Jul 8, 2026
…643 by @hqhq1025) — adopted with review fixes F1-F3+F5 (#656)

Adopts PR #643 (unified Automation: heartbeat + cron, single tool) onto
current main as a squash, preserving main's ToolCardBody dispatch and the
#647 tool-activity refactors, with the maintainer's review fixes:

- F1 (HIGH, automation-scheduler.ts): the busy-session defer budget is a
  ~45min wall-clock window (DEFER_WINDOW_MS, equivalent to the old
  wakeup-scheduler's 5s→5min exponential backoff), replacing the ~120s
  24-retry cap. A transient busy window no longer terminally expires a
  `once` automation — skipFire only runs when the window is exhausted.
- F2 (HIGH, automation-can-fire.ts): 'waiting_for_user' joins
  HEARTBEAT_IDLE_STATUSES (#639 decision — the wakeup's home scenario is
  starting a turn in place of the user); the desktop canfire test now pins
  the new set instead of the opposite.
- F3 (MEDIUM, automation-state.ts): computeJitter ported verbatim from the
  old wakeup-scheduler and wired into computeNextFire — recurring
  (interval/cron) re-schedules get up to 10% delay jitter capped at 15min,
  one-shot fires landing on :00/:30 get up to 90s early jitter, computed on
  the actual fire timestamp. Mirrored the old jitter unit tests and added
  wiring coverage; AutomationManagerDeps gains an injectable `random`.
- F5 (MEDIUM, packages/ui/tool-activity.tsx): AutomationResultPreview card
  (created/deleted/listed, localized) replaces the deleted
  CronJobResultPreview inside the shared ToolCardBody dispatch, plus a new
  desktop contract test binding the runtime tool's real output strings to
  the rendered card.
- LOW: MAX_TERMINAL_KEPT 5 → 50 (old wakeup history cap) and the
  model-facing list now surfaces deferred fire attempts (deferredFireCount),
  mirroring the old CronList fire_attempts.
- Fix: the CLI scheduler tick timer is unref()ed — the PR's always-on 5s
  tick kept the Node event loop alive, hanging any bootstrap consumer that
  exits without close() (this hung the CLI test suite indefinitely). Also
  allow-listed the PR's CLI persistence warnings in check-console.

Original feature authored by @hqhq1025 in #643.
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.

refactor(ui): unify streaming (live) and committed answer onto one render path

2 participants