refactor(ui): unify streaming and settled answers onto a single render path (#642) - #647
Conversation
…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).
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
left a comment
There was a problem hiding this comment.
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.
…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.
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 stablekey={turnId}node — no unmount.What changed
TurnViewgains an optionalliveStreamingprop; only theturn.turnId === tailTurnIdTurnView receives a fresh object per delta (so only it re-renders), every sibling gets a stableundefinedand skips via memo. The standalonemaka-turn-streamingsubtree and its alignment special-cases are deleted. The perf invariant holds: exactly one TurnView re-renders per frame, same as before.group-hover/answer,group-hover/usermsg, withfocus-withinpreserving a11y). Height is reserved throughout, so the handoff is height-neutral. The user-message time is now an absoluteHH:mm.content-visibility: visibleoverride moves from.maka-turn-streamingto.maka-turn[data-live-streaming="true"]; dead rules removed.drainAssistantStreamingnow refreshes committed history before clearing the live buffer (refresh-before-clear), holding the live answer until the committed message lands — no unmount gap.max-h-64scroll 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) dropsml-autoso 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 typecheckclean;@maka/ui46 +@maka/desktop2257 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).maka-turn-streamingnode, 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.