🤖 perf: hide in-progress line, fade completed lines in temporally#3221
🤖 perf: hide in-progress line, fade completed lines in temporally#3221ammar-agent wants to merge 12 commits intomainfrom
Conversation
Adds a soft, GPU-cheap fade-in to each newly-mounted top-level markdown block while a message is streaming. Maps user's mental model of "line-by-line" onto markdown blocks: paragraph, heading, list item, fence, blockquote, table, hr — fires once per new block at mount. Mechanics - TypewriterMarkdown sets data-streaming="true" on its wrapper while the message is streaming. Absent (not "false") on completed/replayed messages so the CSS rule cannot match historical transcripts. - MarkdownCore appends "streamdown-root" to the className passed through to Streamdown's root div — a stable CSS hook with no extra wrapper. - globals.css adds a keyframes + block-level selector list scoped via prefers-reduced-motion. Inline elements (<strong>/<em>/<a>) are intentionally excluded because parseIncompleteMarkdown can transiently insert/remove them as remend repairs unterminated tokens. Why this works - Streamdown keys each block by useId+index and wraps it in React.memo, so existing blocks are reconciled in place when their content grows. Only newly-arriving top-level blocks mount fresh — and CSS animation on a freshly-mounted child fires exactly once at mount. - No JS bookkeeping, no render-phase work, no prop changes. Per-character pacing inside SmoothTextEngine still operates inside each block; the new fade-in layer is purely additive at block boundaries.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94ae3801ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex P2 on PR #3221: replay rows are emitted with isStreaming=true and streamPresentation.source="replay" while the backend rebuilds history on reconnect. Gating data-streaming on isStreaming alone would set the attribute and animate every block of every replayed message — exactly what we wanted to avoid. Compute isLiveStreaming = isStreaming && streamSource !== "replay" and gate the data-streaming attribute on that. Completed messages already have isStreaming=false, so the attribute is naturally absent in their case too. Test extended to assert all three cases: live → "true", replay → absent, completed → absent.
|
@codex review Addressed your P2: |
|
Codex Review: Didn't find any major issues. You're on a roll. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Replace the per-block fade-in (which fired only once per paragraph at mount, then text grew silently inside) with a per-word blur-fade-in. Mechanism: - rehypeSplitWordsForFade walks the HAST tree and wraps each whitespace-bounded word in <span class="sd-word">. Skips inside <pre>/<code>/KaTeX/Mermaid subtrees so code, math, and diagrams stay structurally intact. - MarkdownCore swaps in this rehype plugin only while parseIncompleteMarkdown=true (i.e., during streaming) — completed/ static markdown still renders without word spans (no extra DOM weight). - globals.css replaces the per-block keyframes with sd-word-blur-in (filter: blur(6px) → blur(0), opacity 0 → 1, 500ms ease-out). Still gated on [data-streaming="true"] and prefers-reduced-motion. Why per-word - The user reported the prior per-block effect was invisible in thinking blocks (which are typically a single paragraph — block fade-in fires once at mount, then text grows silently inside the same <p>). Per-word also animates inside an already-mounted block: each new whitespace boundary mounts a fresh span and triggers a fade for that word and only that word. - Stable identity at every word position: as text grows from "Hel" → "Hello", react-markdown reconciles the same span in place; only fresh whitespace boundaries mount new spans. Completed words stay static while only new words fade in. Tests - New rehypeSplitWordsForFade.test.ts covers the core invariants: word/whitespace tokenization, partial trailing word, empty input, pre/code skip, KaTeX skip, recursion into inline (<strong>/<em>).
|
@codex review Pivoted the design after self-dogfood feedback. The previous per-block fade-in was invisible inside thinking blocks (typically one paragraph — the block fade fires once at mount, text grows silently inside). Now uses per-word blur-fade-in via a small rehype plugin ( Skips 7 new tests in |
|
Codex Review: Didn't find any major issues. Delightful! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Upstream regression: @smithy/util-retry@4.3.7 was published at
2026-05-02T04:09:26Z with workspace:^ refs that escaped the smithy
monorepo. Any lockfile-free bun install (used by both
scripts/check-bench-agent.sh and scripts/smoke-test.sh — which mimic
'bun x mux@latest' for end users) fails to resolve those refs.
Reproduction outside this repo:
mkdir t && cd t
echo '{"dependencies":{"@aws-sdk/credential-providers":"^3.940.0"}}' > package.json
bun install --ignore-scripts
# → 'Workspace dependency "@smithy/types" not found'
This breaks every PR opened/pushed after 04:09Z (PR #3219 was lucky to
merge ~30 min before). Add an npm-style overrides pin to <=4.3.6 (the
last known-good release, 2026-04-28) until smithy republishes.
Verified locally: 'bash scripts/check-bench-agent.sh' now passes, and
@smithy/util-retry resolves to 4.3.6 in a fresh lockfile-free install.
Companion to the @smithy/util-retry pin (previous commit). The smoke test runs 'npm install --no-save <tarball>' in a fresh directory whose package.json declares only mux as a dep — npm doesn't honor the tarball's nested npm-shrinkwrap.json for transitive resolution, so without an override at the smoke-test root, transitive @aws-sdk/* deps still re-resolve to the latest @smithy/util-retry (4.3.7, broken). Mirror the workspace package.json's 'overrides' block into the smoke test's package.json so tarball install behaves identically to a real 'bun x mux@latest' user environment. Verified locally: smoke test now passes end-to-end (server boots, healthcheck, oRPC HTTP + WebSocket tests all green) with the override correctly forcing @smithy/util-retry to 4.3.6.
User feedback: the 6px blur was a touch too pronounced. 3px keeps the soft entry feel while letting word shapes resolve faster — closer to a Grok-style settle than a heavy reveal.
User feedback: per-word fade caused horizontal jitter inside a single
visual row because words within the same paragraph mounted at slightly
different times (deltas arrive token-by-token), so each row read as a
shimmer instead of a coherent reveal.
Switch the unit from word to line. The rehype plugin now wraps each
text node in a single <span class="sd-line"> instead of splitting on
whitespace. As text grows inside an existing span, react-markdown
reconciles in place — no remount, no re-fade. A new fade only fires
when a real boundary is crossed:
- new paragraph (new <p> mounts → its first text-node span mounts)
- new <br> (reasoning content with preserveLineBreaks=true runs
remarkBreaks, which converts \n → <br>; each text node between
breaks is a separate line span)
- new list item, table cell, blockquote line, etc.
Renamed plugin/file/test/class for clarity:
rehypeSplitWordsForFade → rehypeWrapLinesForFade
.sd-word → .sd-line
@Keyframes sd-word-blur-in → sd-line-blur-in
Behavior unchanged for skip rules: <pre>/<code>/KaTeX/Mermaid subtrees
are still excluded. Blur duration (500ms) and intensity (3px) preserved.
Long single paragraphs that grow without internal newlines (the rare
case for long-form reasoning) now grow silently inside one line span —
which is a minor regression on word-by-word visibility but a clear win
on horizontal jitter, per user direction.
Replace the per-line span-wrapping rehype plugin with a CSS mask gradient on the streaming markdown wrapper. The mask operates on rendered geometry rather than the AST/HAST tree, so: - A long paragraph that wraps to N visual rows is treated identically to N paragraphs of one row each — what matters is the bottom edge. - Source-emitted line breaks no longer affect the fade; only what the user sees in the transcript does. - Per-word horizontal jitter goes away (no DOM splitting at all). The bottom 2em of every live streaming message is masked with a linear-gradient from black to transparent. As the smoothing engine reveals characters and the message grows, each new visual row emerges from the gradient zone (transparent at the bottom) and settles into solid as it scrolls upward. GPU-accelerated, no JS, no measurement, no edge cases on reflow. Net diff: ~−240 LoC (delete plugin + test, simplify MarkdownCore). Co-authored-by: Mux <noreply@coder.com>
|
@codex review Pivoted from per-AST-line span wrapping to a viewport-aware bottom-edge mask gradient. The mask operates on rendered geometry, so it tracks visual rows in the transcript rather than AST/HAST text nodes — fixing both prior iterations' weakness (per-line missed long-paragraph wrap-to-N-rows; per-word had horizontal jitter). Net diff: ~−240 LoC. The rehype plugin and its test are deleted. Streaming markdown now goes through |
|
Codex Review: Didn't find any major issues. What shall we delve into next? ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
The prior static gradient mask obfuscated the line currently being typed at low opacity, so users saw partial words at varying transparency. Pivot to: hide the in-progress line entirely, then fade in each completed line as a unit at the moment its visual row wraps to the next. Mental model (rendered transcript A → B → C): - While B is partially typed, the user sees only A. B is hidden. - When B's visual row completes (text wraps to row C), B fades in over ~250ms with no horizontal stagger, no tear, no jitter. Implementation: CSS (globals.css) - Sharp linear-gradient mask hides the bottom 1.6em (~1 visual line at line-height 1.6). A 0.4em fade band right above the boundary softens the edge. - New `@property --reveal-y` (typed <length>, initial 0px) is an extra offset that pushes the transparent strip upward. JS (TypewriterMarkdown.tsx) - ResizeObserver on the markdown root tracks rendered height growth (the signal that maps directly to visual rows in the user's viewport — not AST/HAST). - On any positive delta, Element.animate() pulses --reveal-y from `delta` → 0 over 250ms. The mask gradient sweeps past the new content, fading it in temporally. - ResizeObserver presence is guarded so happy-dom test environments degrade silently; real Chromium always has it. Why this is genuinely viewport-aware: - Trigger is rendered geometry. A long paragraph wrapping to N visual rows reveals one row at a time; one AST text node doesn't fool the reveal. Width-driven re-wraps from sidebar collapse, window resize, and zoom all participate naturally because they change offsetHeight. Co-authored-by: Mux <noreply@coder.com>
|
@codex review Pivot from static gradient → JS-driven temporal fade. The prior commit's gradient obfuscated the in-progress line at low opacity; this commit hides it sharply with a mask, then pulses a Trigger is rendered geometry ( |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e73af7c023
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The mask was unconditionally hiding the bottom 1.6em (~1 visual line), which blanked out short replies and stream starts before the first wrap. Codex P1: 'users can see a blank assistant message until a second line wraps or the stream completes, which makes live streaming look stalled'. Gate the mask on a new `data-stream-hide-tail` attribute, set by JS only when rendered height is at least ~1.5 line-heights (between 1 and 2 lines). Below that, the message renders fully visible. The threshold is computed from the element's actual computed line-height so it scales with font-size and line-height overrides. Also skip the WAAPI pulse on the first-time threshold crossing (1 line → 2 lines): the about-to-be-completed first line was already visible without a mask, so a pulse would briefly hide and re-fade it in (a flicker). Subsequent wraps (2+ lines stable, growing) still pulse normally. Co-authored-by: Mux <noreply@coder.com>
|
@codex review Addressed the P1: the mask is now gated on a new Also skipped the WAAPI pulse on the first-time threshold crossing — the about-to-be-completed first line was already visible without the mask, so a pulse would briefly hide and re-fade it (flicker). Subsequent wraps (2+ lines stable, growing) still pulse normally. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 108791d195
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`el.toggleAttribute(name, force)` sets the attribute with an EMPTY value, not "true", so the CSS selector `[data-stream-hide-tail="true"]` never matched and the mask was never applied. Use a presence selector `[data-stream-hide-tail]` instead. Codex P1. Co-authored-by: Mux <noreply@coder.com>
|
@codex review Fixed: switched to presence selector |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 031f6892ab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Upstream published @smithy/util-retry@4.3.8 ~32min after the broken 4.3.7, fixing the workspace:^ resolution failure. Verified clean install of latest in a fresh root works. Revert: 1. package.json: drop the `overrides` block pinning util-retry to <=4.3.6. 2. scripts/smoke-test.sh: drop the ROOT_DIR capture and node-script that injected the workspace's overrides into the smoke-test root's package.json. Restore the original `cat >package.json <<EOF` minimal init. Codex P1: the override injection defeated the purpose of SKIP_SHRINKWRAP=1 (lockfile-free end-user-equivalent installs), which exists to catch upstream regressions that `bunx mux@latest` / `npx` users would hit. By mirroring our root overrides, the smoke test was masking exactly the failure mode it's designed to catch. With the upstream fix in place, the workaround is no longer needed and the smoke test can return to its canonical form. Co-authored-by: Mux <noreply@coder.com>
|
@codex review Fixed: reverted both the |
|
Codex Review: Didn't find any major issues. Swish! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Summary
Hide the in-progress visual line entirely (no partial obfuscation), then fade in each completed line as a unit at the moment its visual row wraps to the next. The trigger is rendered geometry (
offsetHeightgrowth), so it tracks visual rows in the user's viewport — not the AST/HAST tree.Mental model
Given lines A → B → C in the rendered transcript:
This is the user's framing verbatim:
Implementation
CSS (
globals.css)A linear-gradient mask hides the bottom 1.6em (~1 visual line at line-height 1.6). A thin 0.4em fade band right above the boundary softens the edge so completed lines don't snap with a hard cutoff.
--reveal-yis an extra offset that pushes the transparent strip upward; resting value is 0px (only the in-progress line is hidden).JS (
TypewriterMarkdown.tsx)A
useLayoutEffectregisters aResizeObserveron the markdown root. WhenoffsetHeightgrows byΔ,el.animate()pulses--reveal-yfromΔ→0over 250ms. During the pulse, the just-revealed strip is initially also covered by the mask, then is uncovered as the property animates back — producing a temporal fade-in.Custom-property animation requires the
@propertydeclaration so the syntax<length>is interpolatable. WAAPI is preferred over CSS transitions because the start value depends on the runtime delta; once started, the animation runs on the compositor thread.ResizeObserverpresence is guarded for happy-dom test environments; real Electron Chromium always has it.Why this is genuinely viewport-aware
offsetHeight, so the fade naturally fires at the right moments.<br>boundaries (reasoning content's preserveLineBreaks) work the same. Each break that adds height triggers a fade.Validation
make typecheck(clean rebuild, no incremental cache): passes.make lint: passes.make fmt-check: passes.bun test src/browser/features/Messages/: 71 pass.The existing
data-streaminggating test (live → "true", replay/completed → absent) is the load-bearing invariant for the CSS rule; it remains as-is.Risks
Low.
ResizeObserveris disconnected on stream end / unmount; in-flight WAAPI animations are cancelled. Cancelled custom-property animations snap--reveal-yback to its declaredinitial-value: 0px.@propertyand WAAPI custom-property animation require Chromium ≥ 85 / Safari ≥ 16.4 / Firefox ≥ 128. Mux ships in Electron with modern Chromium; non-supporting runtimes silently skip the animation (catch + null).data-streamingis removed, the previously-hidden in-progress line snaps to visible. The smoothing engine has finished revealing characters by then, so the snap is barely perceptible.Pains
Four iterations to land on the right level of abstraction:
Iteration history
<p>/<h*>/<li>fade once at mount): worked for new paragraphs but invisible inside thinking blocks (typically one paragraph — animation fires once at mount, then text grows silently inside the same<p>).<p>wrapping to N visual rows is one span, and once mounted, in-place text growth fires no fade.The current design (this commit) hides the in-progress line entirely with a sharp clip, and uses JS to pulse a CSS custom property when the rendered height grows — giving a temporal fade-in keyed to actual visual-row events. Each iteration's commit is preserved in the branch history for context.
Generated with
mux• Model:anthropic:claude-opus-4-7• Thinking:max• Cost:$85.42