Skip to content

fix(stream): stop tearing live progress words across block boundaries - #7082

Merged
5 commits merged into
nesquena:masterfrom
ruizanthony:fix/live-prose-fade-cursor-source-space
Sep 8, 2026
Merged

5 commits merged into
nesquena:masterfrom
ruizanthony:fix/live-prose-fade-cursor-source-space

Conversation

@ruizanthony

Copy link
Copy Markdown
Contributor

Summary

Live progress rows in Transparent Stream can be torn in the middle of a word, with the tail rendered on its own line:

Ces deu
x fichiers passent. Je traite les régressions restantes.

The streamed text itself is intact — this is purely a client-side render defect. The server sends clean deltas ("\n\nCes", " deux fichiers passent…") and the persisted final message is correct.

Root cause

Two changes landed on the same day and are individually correct, but interact badly:

PR Change Consequence
#5455 Render live prose incrementally through smd Builds rows that never set data-stream-fade-text
#5506 Restore prose-level fade Reads that cursor, falling back to body.textContent

The vendored streaming-markdown parser always holds the most recently written character in its pending buffer, so a live row's rendered text trails its source text by exactly one character:

source written : "Ces deux"
body.textContent: "Ces deu"     ← one character behind

_refreshTransparentFadeProseRow() computed its resume point as:

const currentText = String(existing.getAttribute('data-stream-fade-text') || (body && body.textContent) || '');

For incrementally built rows the attribute is absent, so the fallback applies and the cursor is expressed in rendered-text space while nextText is in source-text space. The resulting delta nextText.slice(currentText.length) starts one character early — mid-word — and _appendTransparentFadeText() appended it to the root of .msg-body, i.e. as a sibling of the parser's open <p>. A <p> is a block box, so the tail starts a new line box.

Observed DOM before this patch:

[0] <p>    "Ces deu"
[1] <span> "x"
[2] <span> "fichiers"

Fix

Two independent layers, so a single stale cursor can no longer tear a word:

  1. Resume strictly in source space. body.textContent is no longer used as a cursor substitute. When no cursor exists we cannot know how much of the source is already rendered, so the body is rebuilt rather than guessing a delta.
  2. Append inside the trailing block element (_transparentFadeAppendTarget) instead of at the .msg-body root, so a continuation of the current sentence stays on the same line.

Behaviour that is deliberately preserved:

Tests

New tests/test_live_prose_fade_cursor_source_space.py drives the real vendored parser to construct a live row exactly as production does, then runs the real reconciler over it:

  • asserts the precondition that the parser genuinely lags its source by one character;
  • fails if any word is torn across a block boundary;
  • fails if text is stranded outside the block element;
  • second test pins the existing cursor-present incremental contract, including span node identity.

RED on dc3bf44e without the static/ui.js change:

AssertionError: live prose row was torn mid-word across a block boundary: ...'Ces de' | 'ux'...

GREEN with it.

tests/test_issue5367_transparent_live_row_reconcile.py extracts _appendTransparentFadeText into a Node harness, so it gains the new helper dependency (_transparentFadeAppendTarget plus its block-tag set). Without that it would throw ReferenceError.

Verification

python -m pytest tests/test_live_prose_fade_cursor_source_space.py \
                 tests/test_issue5367_transparent_live_row_reconcile.py -q

# adjacent streaming / anchor-scene suites
python -m pytest tests/test_anchor_fallback_ownership.py \
  tests/test_anchor_scene_persistence.py \
  tests/test_issue3397_transparent_stream_prose_segments.py \
  tests/test_issue3820_chat_activity_display_mode.py \
  tests/test_issue5700_worklog_event_timestamps.py \
  tests/test_issue5720_reasoning_owner.py \
  tests/test_issue5749_transparent_stream_prefix_dedupe.py \
  tests/test_issue5854_anchor_scene_split.py \
  tests/test_issue5966_transparent_stream_lazy_dom.py \
  tests/test_smooth_text_fade.py tests/test_smd_media_in_stream.py \
  tests/test_live_activity_timeline.py tests/test_live_anchor_progress_echo.py \
  tests/test_live_anchor_stable_run_identity.py \
  tests/test_live_to_final_anchor_visible_order.py \
  tests/test_stable_assistant_turn_anchor_normalizer.py \
  tests/test_stable_assistant_turn_anchor_phase0.py \
  tests/test_stable_assistant_turn_anchor_registry.py \
  tests/test_issue6220_id_linked_tool_anchor_hydration.py -q

node --check static/ui.js
python scripts/ruff_lint.py --diff origin/master

415 tests pass, no regressions.

Notes

Touches only the transparent-stream live prose render path; no server, session, or persistence change. Independent of #6264 (scroll bounce), which does not modify the cursor or the append target — whichever lands second rebases trivially.

@greptile-apps

greptile-apps Bot commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR fixes torn live-progress words by keeping fade reconciliation in source-text space and appending continuations within the active Markdown block. Follow-up changes preserve the parser-owned Markdown DOM and leave pending text under parser control.

  • Exposes the rendered-prefix mute helper for transparent-stream reconciliation.
  • Adopts parser-owned live rows rather than flattening or repeatedly cloning their Markdown DOM.
  • Adds regression coverage for source/render cursor divergence, Markdown preservation, pending tails, fade-node identity, and block-boundary insertion.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
static/ui.js Reconciles live prose using source-space cursors, block-local insertion, and parser-owned row adoption; the previously reported Markdown and pending-tail defects are addressed.
static/messages.js Exposes the stateless rendered-prefix muting helper needed to preserve already-visible fade state during reconciliation.
tests/test_live_prose_fade_cursor_source_space.py Adds focused real-parser regression coverage for word integrity, Markdown preservation, pending parser text, and live-node identity.
tests/test_issue5367_transparent_live_row_reconcile.py Updates the extracted reconciler harness with the new append-target helper dependency.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Streamed source delta] --> B[Incremental Markdown parser]
  B --> C[Parser-owned live row]
  C --> D{Existing source cursor?}
  D -->|Yes| E[Append source-space delta]
  D -->|No| F[Adopt or preserve parsed DOM]
  E --> G[Trailing Markdown block]
  F --> G
  G --> H[Intact live prose and stable fade nodes]
Loading

Reviews (6): Last reviewed commit: "fix(stream): adopt parser-owned fade row..." | Re-trigger Greptile

Comment thread static/ui.js Outdated
@nesquena-hermes nesquena-hermes added the size:L Large PR (>10 files or >250 LOC) label Aug 16, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Reading static/ui.js:13641-13704 on origin/master alongside this diff, the root cause and both fix layers are sound. The body.textContent fallback at line 13672 genuinely mixes rendered-space and source-space cursors, and the vendored parser's one-character lag makes the resulting nextText.slice(currentText.length) delta start mid-word. Resuming strictly from data-stream-fade-text (the hasCursor guard) plus appending inside the trailing block instead of at the .msg-body root is the right shape, and the RED/GREEN test driving the real parser is exactly the kind of coverage this path needed.

One edge in the append target

_transparentFadeAppendTarget descends only one level and treats every tag in the set as a valid insertion point:

const _TRANSPARENT_FADE_BLOCK_TAGS = new Set([
  'P','DIV','H1','H2','H3','H4','H5','H6','BLOCKQUOTE','LI','UL','OL','PRE','TABLE',
]);

For the reported prose case the trailing child is a <p> and this is correct. But smd (driven by _anchorProseIncrementalNode, static/messages.js:3943) also emits container blocks. When the parser's open block is a list item, .msg-body's last child is the <ul>/<ol>, not the <li> — so _transparentFadeAppendTarget returns the container and the fade fragment lands as a direct child of <ul>/<ol> (or <table>), which is not a valid inline location and can still render off the intended line.

Suggestion

Either drop the pure-container tags (UL, OL, TABLE) from the set so the target falls back to body for those (no worse than a leaf-block miss), or descend into the container's trailing block element. LI, P, H*, BLOCKQUOTE, PRE are correct leaf targets and should stay.

A cheap way to pin this: extend test_live_prose_fade_cursor_source_space.py with a streamed list item ("- Ces" + " deux fichiers") and assert the tail is not stranded as a direct child of the <ul>. If that already stays intact in practice because a continuation delta never arrives mid-list-item, a one-line comment noting why the container tags are safe would document the intent.

Prose path itself looks correct — this is only about the non-<p> trailing blocks the same helper now handles.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Really solid fix, @ruizanthony — the root-cause analysis is exactly right and the gate confirms it. The regression is real and this restores the universal live-streaming convention (ChatGPT / Claude / Codex all keep a partially-streamed word in-flow; master tore it across the block boundary as "Ces deu" / "x fichiers").

Gate results:

  • Codex: SAFE TO SHIP — verified exact cursor behavior across ASCII/emoji/CJK, live-to-final + reattach paths, default-render isolation; no regression risk.
  • Non-vacuous proof: your two regression tests FAIL on clean master (the tear is genuinely present) and PASS on the fix — confirmed by re-running them against origin/master. Plus 1028 focused stream/fade/transparent tests green.
  • Fable UX: SHIP-WITH-UX-FIXES — confirms the fix is in the right layer (row reconciler, not a CSS band-aid), reuses the existing fade machinery, no regression to the fade animation / thinking rows / settled message, and correct under RTL. Caret unaffected (it's suppressed in fade mode).

One minor UX-polish fix before it ships, and it's in the app's own established pattern:

Should-fix — mute the already-rendered prefix on the no-cursor rebuild branch

The no-cursor rebuild branch of _refreshTransparentFadeProseRow (the branch that now fires in exactly the scenario that used to tear, since incremental rows never set data-stream-fade-text) clears the body and re-wraps all words as .is-new. So the whole visible row dips to opacity 0 and fades back over ~620ms, and because every node is replaced, native scroll anchoring may pick a new anchor (the one-time vertical bounce the #6257 comment in _bindTransparentFadeCleanup warns about).

This is strictly better than the persistent torn word, it's one-time per row, and reduced-motion users never see it — but it contradicts the app's own #6783 standard, where messages.js (_streamFadeMuteRenderedPrefix, messages.js:4997) deliberately mutes exactly this "replay the fade on ALL visible words at once" defect. The ui.js rebuild branch has no such mute, and the helper is currently closure-private.

Fix (~3 lines): on the rebuild branch, snapshot body.textContent before clearing, then apply the messages.js _streamFadeMuteRenderedPrefix idiom so only genuinely-new tail words animate. Export it on window the same way __anchorProseIncrementalNode is exposed. This kills both the one-time whole-row fade flash and the all-new-nodes scroll-anchor bounce, and it also repairs the pre-existing rewind case where messages.js carefully mutes the fade on the new node only for the ui.js reconciler to rebuild it as all-is-new.

Once that lands, this is a clean ship on the crown-jewel streaming surface. Re-push and I'll re-gate + capture the before/after mid-stream proof for the maintainer. Nice work tracking this to the one-character parser lag.

ruizanthony added a commit to ruizanthony/hermes-webui that referenced this pull request Aug 17, 2026
…w rebuild

Review follow-ups for nesquena#7082:

- Maintainer should-fix: the no-cursor rebuild branch of
  _refreshTransparentFadeProseRow cleared the body and re-wrapped every
  word as .is-new, so the whole visible row dipped to opacity 0 and
  faded back (~620ms), and wholesale node replacement invited the
  one-time scroll-anchor bounce (nesquena#6257). Now the rendered text is
  snapshotted before the clear and the messages.js
  _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
  so only genuinely-new tail words animate. The helper is exported on
  window (__streamFadeMuteRenderedPrefix) the same way
  __anchorProseIncrementalNode is.

- Greptile P1: the same branch passed raw source text to
  _appendTransparentFadeText, flattening live markdown rows to literal
  syntax (links/emphasis/headings lost until settlement). The rebuild
  now adopts a deep clone of the candidate's parsed .msg-body (the
  incremental streaming-markdown output) when one exists, so the parsed
  DOM survives; the plain-text rebuild remains only as fallback for
  candidates without a parsed body. The resume cursor stays in source
  space when rendered text diverges from source (markdown), and shrinks
  to the rendered prefix for plain prose so the parser's held-back
  character is re-appended on the next delta instead of dropped.

Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
Comment thread static/ui.js Outdated

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@ruizanthony, the original word-tearing fix still holds, and the two follow-up commits do fix the requested full-row fade replay. The re-gate found one objective regression in the new parser-DOM clone path, so this exact head still needs a correction.

Must fix: keep one parser/DOM authority after the cursorless rebuild

static/ui.js:_refreshTransparentFadeProseRow now clones the candidate streaming-Markdown body into the preserved visible row, then stores a trimmed source cursor. On the next source growth, hasCursor is true, so the normal branch sends the source delta to _appendTransparentFadeText as raw text. The live parser keeps updating the detached candidate body, not the visible clone.

A sandboxed diagnostic drove the real vendored parser through this sequence:

Hello **
Hello **world** done 

At the second refresh, the candidate parser DOM contained <strong>world</strong>, but the visible preserved row had no <strong> and displayed literal **world**. The cursor then advanced to the full source, so the parser-owned structure could not repair that live row until later canonical settlement.

Please keep one authority after the clone transition. Either make the fresh parser-owned candidate row/body the visible keyed owner, or continue reconciling later updates from the candidate's parsed DOM. Do not clone parser output and then append later source bytes through _appendTransparentFadeText. Preserve the rendered-prefix mute behavior, and keep parser pending/MEDIA tails owned by the parser.

Please also add a regression to tests/test_live_prose_fade_cursor_source_space.py that performs two reconciliations across incomplete-to-complete Markdown using the real parser. It should assert that the visible row contains <strong>world</strong>, contains no literal **, has every byte exactly once, and stays correct through finalization. The current five tests pass, but they stop at the initial clone or append only punctuation, so they miss this transition.

Gate evidence at bf087f8b505e51444af0080e34ed5fd2aba4d2a0:

  • threat scan: CLEAN
  • focused sandbox suite: 5 passed
  • reviewer diagnostic: deterministic real-parser reproduction
  • worktree returned clean after the diagnostic

Once this lands, the existing Nathan visual-sign-off requirement still applies because this changes visible live Markdown/fade behavior.

ruizanthony added a commit to ruizanthony/hermes-webui that referenced this pull request Aug 18, 2026
…w rebuild

Review follow-ups for nesquena#7082:

- Maintainer should-fix: the no-cursor rebuild branch of
  _refreshTransparentFadeProseRow cleared the body and re-wrapped every
  word as .is-new, so the whole visible row dipped to opacity 0 and
  faded back (~620ms), and wholesale node replacement invited the
  one-time scroll-anchor bounce (nesquena#6257). Now the rendered text is
  snapshotted before the clear and the messages.js
  _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
  so only genuinely-new tail words animate. The helper is exported on
  window (__streamFadeMuteRenderedPrefix) the same way
  __anchorProseIncrementalNode is.

- Greptile P1: the same branch passed raw source text to
  _appendTransparentFadeText, flattening live markdown rows to literal
  syntax (links/emphasis/headings lost until settlement). The rebuild
  now adopts a deep clone of the candidate's parsed .msg-body (the
  incremental streaming-markdown output) when one exists, so the parsed
  DOM survives; the plain-text rebuild remains only as fallback for
  candidates without a parsed body. The resume cursor stays in source
  space when rendered text diverges from source (markdown), and shrinks
  to the rendered prefix for plain prose so the parser's held-back
  character is re-appended on the next delta instead of dropped.

Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
@ruizanthony
ruizanthony force-pushed the fix/live-prose-fade-cursor-source-space branch from bf087f8 to 3dbe47b Compare August 18, 2026 19:39
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Re-certification request on exact head 3dbe47b24ae27b30b1e63938c921759aa614abd3.

The 2026-08-18 must-fix is addressed in static/ui.js:_refreshTransparentFadeProseRow:

  • A candidate with __smdParser is now parser-owned for the rest of the live turn.
  • Later growth is reconciled by adopting that parsed DOM again.
  • The clone-then-_appendTransparentFadeText path is gone for parser-owned rows, so Hello ** → Hello **world** done keeps <strong>world</strong> instead of literal **world**.
  • Rendered-prefix mute is unchanged.
  • Parser pending / MEDIA tails stay on the parser until it flushes them. No source-space resume cursor is published after a parser-owned rebuild.

Regression added: test_live_prose_keeps_parser_authority_across_markdown_close (two real-parser reconciliations + finalization). The previous pending-tail test now asserts the parser-owned contract instead of the rejected source-append.

Local focused slice on this head: 44 passed (test_live_prose_fade_cursor_source_space.py, test_issue5367_transparent_live_row_reconcile.py, test_smooth_text_fade.py, test_anchor_prose_incremental_finalize.py).

Please re-gate this exact head. Nathan visual sign-off still applies.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — RED ⛔ (prior fixes closed; post-rewind fade identity still regresses)

Certified contributor head: 3dbe47b24ae27b30b1e63938c921759aa614abd3
Pinned base / tested integration: 63a562f6ed4e20e63377674e4d7b85b75b7fc4ec / 3dbe47b24ae27b30b1e63938c921759aa614abd3
Rebase proof: already based on current master; aggregate patchset remained exact.

Verdict: gate-fail. The two prior blockers are closed: source/rendered cursor mixing no longer tears words, and later Markdown growth keeps parser structure instead of leaking literal **world**. However, the repaired parser-owned branch deep-clones the parser DOM on every post-rewind growth frame. That cuts off the previous tail word's intended fade and breaks the stable-node contract used to prevent scroll-anchor jitter.

What I ran

Gate Result
Exact-head threat scan CLEAN, score 0
Rebase-first / patch equivalence Clean no-op onto pinned master; patchset identical
Full sandboxed pytest suite, serial, to completion 14,697 passed, 94 skipped, 1 xfailed, 2 xpassed, 34 subtests; 9 failed + 2 errors
Same-box frozen-base control Exact same 9 failed + 2 error node set reproduced; zero PR-owned suite residuals
Codex regression gate SHIP ONLY WITH FIXES on the fade/node-identity defect below
Opus 4.8 senior review GREEN dissent, but independently identified the same post-rewind full-tree clone/node churn as advisory B
Fable UX gate SHIP-UX, while requiring motion/scroll/settlement proof after engineering convergence
Reviewer-owned sandbox probe 1 passed; production reconciler independently reproduced the exact unsafe two-growth outcome
Crown-jewel live stream gate GREEN across transparent, compact-worklog, and hide-all modes; this steady-state fixture does not force the post-rewind existing !== node branch below
Static gates Ruff-forward CLEAN, ESLint runtime CLEAN, scope-undef CLEAN, git diff --check clean

The suite residuals are inherited box/topology failures: atomic group ownership, four linked-worktree gitignore checks, one connection-error lifecycle case, keyless LM Studio readiness, two sandbox-home path checks, and two wheel-build setup errors. The exact pinned-base control reproduced every identity.

Prior findings — CLOSED ✅

  • static/ui.js:_refreshTransparentFadeProseRow now resumes only from an explicit source-space cursor; it no longer substitutes lagging rendered text.
  • Parser-owned later growth re-adopts parsed DOM and keeps the source cursor absent, so Hello ** → Hello **world** done retains <strong>world</strong> with no literal delimiters or duplicate/lost text.
  • Rendered-prefix muting is wired through the existing helper and focused real-parser tests are non-vacuous for those closures.

MUST-FIX — post-rewind growth replaces and prematurely settles the prior animated tail (SILENT)

Site: static/ui.js:13854-13867, parser-owned arm of _refreshTransparentFadeProseRow.

Production reachability is the rewind/cache-miss graph where the keyed visible row remains existing, while _anchorProseIncrementalNode owns a separate persistent parser candidate node. _refreshTransparentFadeProseRow returns existing, so that pair remains distinct on each later growth frame.

Current schedule:

  1. First reconciliation clones the parser candidate into the visible body. Prefix muting leaves the genuinely new tail word as .stream-fade-word.is-new.
  2. Roughly one streaming frame later, the parser grows its persistent candidate.
  3. The next reconciliation deep-clones the entire candidate again, replacing the prior tail span. Prefix muting now classifies that prior tail as already visible and strips .is-new from its replacement.
  4. The intended ~620 ms fade is therefore cut off at the ~33 ms stream cadence, and the prior word's DOM identity is lost before animationend; repeated cloning also reopens the scroll-anchor-jitter class the stable-span contract was designed to prevent.

Independent reviewer reproduction through the real production reconciler: after the first frame firstNewWasAnimated=true; after the second growth firstNewStillConnected=false, secondNewSameIdentity=false, secondNewIsNew=false, while only the newest more word remained is-new. This was not accepted from Codex alone; the separate sandbox probe reproduced it exactly.

The all-mode live gate staying GREEN does not disprove this finding: its ordinary fixture remained on the steady existing === node path and reported zero transparent row swaps. The blocker is specifically the post-rewind separate parser-owner branch.

Exact fix and regression

Promote/adopt the actual parser-owned candidate row into the existing row's DOM position once instead of copying its children into the old row. Preserve the old row's relevant interactive/position state, apply rendered-prefix muting to the actual parser body, and return the adopted candidate so later keyed renders hit _refreshTransparentLiveRow(existing === node) and retain parser target plus word-node identity.

Add a deterministic production-reconciler regression with two later growth frames after the separate-owner transition:

  • the first genuinely new tail span stays the exact same node through the second growth;
  • it keeps .is-new until an explicit animationend cleanup;
  • only the second growth's word is newly animated;
  • Markdown structure, pending tails, and final settlement remain exact-once.

UX and screenshots

Fable's code review is SHIP-UX, but the branch is engineering-RED. Realistic motion/scroll/reduced-motion/settlement captures remain deferred until this lifecycle fix converges; no stale screenshots are being presented as approval evidence.

Recommendation

Do not merge this head. Preserve @ruizanthony's correct word-tear and parser-authority repairs, fix the one remaining post-rewind identity transition, add the two-growth animation-lifetime regression, then run a full exact-head re-gate and the required final motion/visual proof.


Gate-certifier layer only. No merge, tag, deploy, close, or contributor-branch write was performed. This certificate is valid only for contributor head 3dbe47b24ae27b30b1e63938c921759aa614abd3.

@nesquena-hermes nesquena-hermes added gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels Aug 18, 2026
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Post-rewind fade identity shipped on exact head 0cc15af93ed476ecc5c3ac73725a32d8400b9ae8.

_refreshTransparentFadeProseRow now promotes the parser-owned candidate row into the existing row's DOM position once, instead of deep-cloning parser children on every later growth frame. Later keyed renders hit existing === node, so the first genuinely-new tail span keeps its node identity and .is-new until animationend. Rendered-prefix mute and parser pending/MEDIA tails are unchanged.

Regression: test_live_prose_post_rewind_keeps_tail_word_identity_across_two_growths drives two growth frames after the separate-owner transition. Focused + adjacent: 15 + 45 passed; node --check and git diff --check clean. Please re-gate this SHA.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — GREEN ✅ (engineering converged; crown-jewel motion approval hold)

Certified contributor head: 0cc15af93ed476ecc5c3ac73725a32d8400b9ae8
Pinned base / tested integration: 63a562f6ed4e20e63377674e4d7b85b75b7fc4ec / exact contributor head
Prior RED head: 3dbe47b24ae27b30b1e63938c921759aa614abd3
Rebase proof: head is already current-base; aggregate contributor patch preserved.

Verdict: gate-pass for engineering and automated stream lifecycle. The prior repeated-clone defect is closed: the separate parser-owned candidate row is promoted into the connected keyed DOM once, state is rehydrated onto it, and later parser growth mutates that same connected body through the existing===node fast path. Final merge remains on the standing visible/motion approval hold because this changes crown-jewel live prose animation.

Authoritative gate

Gate Result
Exact-head threat scan CLEAN, score 0
Rebase-first / patch equivalence Already current-base; exact head tested
Full sandboxed pytest suite, serial, to completion 14,698 passed, 94 skipped, 1 xfailed, 2 xpassed, 34 subtests; 9 failed + 2 errors
Frozen pinned-base attribution Established exact 9-fail/2-error baseline on this pinned master; zero PR-owned residuals
Codex regression gate SAFE TO SHIP; 63 focused/adjacent passes and all four files reviewed
Opus 4.8 stream-lifecycle review GREEN; adoption/cleanup/finalization authority coherent
Fable UX review SHIP-UX; motion evidence still required for maintainer approval
Reviewer-owned adoption probe PASS through production reconciler: candidate returned/connected, stale row disconnected, parser body preserved, first new span retained identity + .is-new across later growth
Crown-jewel live stream gate GREEN across all three modes: transparent stream, compact worklog, hide-all-activity; during-stream, after-done, switch-away/back, reload-after-done, and reload-mid-stream all clean
Static gates Ruff-forward CLEAN, ESLint runtime CLEAN, scope-undef CLEAN, Node syntax and git diff --check clean

The inherited 9 failures + 2 errors are not PR-owned.

Prior RED — CLOSED ✅

The old head copied candidateBody.cloneNode(true) into the stale keyed row on every post-rewind growth. At the next frame, that replaced the prior tail span and prefix mute removed its .is-new, truncating a 620ms fade after roughly one frame.

At this head:

  1. _refreshTransparentFadeProseRow identifies a distinct parser-bound candidate.
  2. It mutes only the already-rendered prefix on the actual candidate body.
  3. It replaces the stale visible row with the parser candidate once, rehydrates preserved state/actions onto the candidate, and returns the candidate.
  4. The render loop consumes the returned row and removes the old key from stale cleanup.
  5. Later parser growth targets the same connected candidate and takes existing===node; no recurring parser-DOM clone exists.

The independent reviewer probe observed:

  • returned row is the exact candidate;
  • candidate is connected and stale row disconnected;
  • exact parser body remains the target;
  • first new word remains the same connected node with .is-new after another parser growth;
  • the next word is also new, and body text is exactly old new more.

The real-parser tests also preserve one parsed <strong>world</strong>, no literal **, no duplicate old/new words, no published source cursor, parser-owned pending/MEDIA tails, and coherent final settlement.

Crown-jewel visible approval hold

The automated stream gate is green, but it does not deliberately force the rare rewind/separate-owner adoption while recording the fade. Before release-lane action, the standing visual gate still requires final realistic evidence:

  1. Desktop recording of live Transparent Stream prose through two or more growth frames and one rewind/adoption event: new tail words fade once, no prior-word replay, no whole-row opacity dip.
  2. Scroll-anchor proof during adoption, both pinned-to-bottom and reader-scrolled-up: zero jump/bounce.
  3. Mobile-width recording with wrapping and no horizontal overflow.
  4. prefers-reduced-motion: reduce: instant appearance, no tear.
  5. Settlement matrix: live→final, switch away/back, reload after done, and reload mid-stream.
  6. Formatted prose with bold, list, code, and a pending MEDIA tail.

Do not send a sparse still as approval evidence; this defect class needs motion plus scroll metrics. Once that proof is captured and visually approved, this exact head is ready for the release agent's final fresh-head check.

Recommendation

Engineering GREEN. Keep the PR on visible-approval hold, not T1/ship-ready, until final motion evidence is approved. Preserve @ruizanthony's attribution. No code change is requested by this gate.


Gate-certifier layer only. No merge, tag, deploy, close, or contributor-branch write was performed. This certificate is valid only for contributor head 0cc15af93ed476ecc5c3ac73725a32d8400b9ae8.

@nesquena-hermes nesquena-hermes added gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent and removed changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address gate-fail Gate found blocking issue(s); fix-spec in comment; awaiting fix/re-push labels Aug 19, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-gate GREEN — the parser-authority fix closed the clone regression

Re-gated the converged head (0cc15af93) end to end, and the prior bounce (the parser-DOM clone path leaving the live parser updating a detached body) is fully resolved:

  • Codex: SAFE TO SHIP. The exact prior-failing sequence (Hello ** → Hello **world** done) fails on the bounce head bf087f8b5 with literal <span>**world**</span>, and passes here with a single <strong>world</strong>, no literal delimiters, and byte-exact final text. The parserOwned branch adopts the actual parser-owned row into the keyed position (removing data-stream-fade-text so later frames take the existing === node grow path, not _appendTransparentFadeText), so there's exactly one authority — no detached-parser double-write. The original word-boundary fix is intact, and the new test_live_prose_fade_cursor_source_space.py is non-vacuous (drives adoption + reconciliation through the real vendored parser, fails against the bounced impl).
  • Full suite: 14,787 passed / 0 failed.
  • Crown-jewel stream gate: GREEN across all three modes (transparent_stream, compact_worklog, hide_all_activity). Motion metrics — rowElementSwaps=0, proseReversals=0, nonProseReanims=0, groupedFramesInTransparent=0, modeFlips=0 — confirm the fade doesn't re-clone/swap/reverse the prose row per frame (which is exactly what a still can't show, so these numbers are the motion proof).

Because this changes visible live-Markdown/fade behavior, it carries the standing visual sign-off requirement — captured live transparent-stream frames and routing them for UX + maintainer review before it lands. Thanks @ruizanthony — clean convergence on the fix-spec.

ruizanthony added a commit to ruizanthony/hermes-webui that referenced this pull request Aug 23, 2026
…w rebuild

Review follow-ups for nesquena#7082:

- Maintainer should-fix: the no-cursor rebuild branch of
  _refreshTransparentFadeProseRow cleared the body and re-wrapped every
  word as .is-new, so the whole visible row dipped to opacity 0 and
  faded back (~620ms), and wholesale node replacement invited the
  one-time scroll-anchor bounce (nesquena#6257). Now the rendered text is
  snapshotted before the clear and the messages.js
  _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
  so only genuinely-new tail words animate. The helper is exported on
  window (__streamFadeMuteRenderedPrefix) the same way
  __anchorProseIncrementalNode is.

- Greptile P1: the same branch passed raw source text to
  _appendTransparentFadeText, flattening live markdown rows to literal
  syntax (links/emphasis/headings lost until settlement). The rebuild
  now adopts a deep clone of the candidate's parsed .msg-body (the
  incremental streaming-markdown output) when one exists, so the parsed
  DOM survives; the plain-text rebuild remains only as fallback for
  candidates without a parsed body. The resume cursor stays in source
  space when rendered text diverges from source (markdown), and shrinks
  to the rendered prefix for plain prose so the parser's held-back
  character is re-appended on the next delta instead of dropped.

Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
@ruizanthony
ruizanthony force-pushed the fix/live-prose-fade-cursor-source-space branch from 0cc15af to ab4d2bc Compare August 23, 2026 23:44
ruizanthony added a commit to ruizanthony/hermes-webui that referenced this pull request Aug 25, 2026
…w rebuild

Review follow-ups for nesquena#7082:

- Maintainer should-fix: the no-cursor rebuild branch of
  _refreshTransparentFadeProseRow cleared the body and re-wrapped every
  word as .is-new, so the whole visible row dipped to opacity 0 and
  faded back (~620ms), and wholesale node replacement invited the
  one-time scroll-anchor bounce (nesquena#6257). Now the rendered text is
  snapshotted before the clear and the messages.js
  _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
  so only genuinely-new tail words animate. The helper is exported on
  window (__streamFadeMuteRenderedPrefix) the same way
  __anchorProseIncrementalNode is.

- Greptile P1: the same branch passed raw source text to
  _appendTransparentFadeText, flattening live markdown rows to literal
  syntax (links/emphasis/headings lost until settlement). The rebuild
  now adopts a deep clone of the candidate's parsed .msg-body (the
  incremental streaming-markdown output) when one exists, so the parsed
  DOM survives; the plain-text rebuild remains only as fallback for
  candidates without a parsed body. The resume cursor stays in source
  space when rendered text diverges from source (markdown), and shrinks
  to the rendered prefix for plain prose so the parser's held-back
  character is re-appended on the next delta instead of dropped.

Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
@ruizanthony
ruizanthony force-pushed the fix/live-prose-fade-cursor-source-space branch from ab4d2bc to 30a0417 Compare August 25, 2026 23:11
Transparent-stream progress rows are built incrementally by
`_anchorProseIncrementalNode`, which streams source text into the vendored
streaming-markdown parser. That parser always holds the most recently written
character in its pending buffer, so a live row's rendered `.msg-body` text
trails its source text by one character.

`_refreshTransparentFadeProseRow` resumed appending from the
`data-stream-fade-text` cursor but fell back to `body.textContent` when the
attribute was absent — exactly the case for incrementally built rows, which
never set it. That fallback mixed two coordinate spaces (rendered text vs
source text), so the computed delta started one character early, in the middle
of a word. The delta was then appended as a sibling of the parser's open `<p>`,
and since `<p>` is a block box the tail rendered on its own line: a single word
split across two lines ("Ces deu" / "x fichiers").

Two independent fixes:

1. Resume strictly from the source-space cursor. When no cursor exists we
   cannot know how much source is already rendered, so rebuild the body
   instead of guessing a delta from rendered text.
2. Append into the trailing block element rather than the `.msg-body` root, so
   a continuation of the current sentence stays on the same line even if a
   cursor is ever stale.

Regression tests drive the real vendored parser to build a live row exactly as
production does, assert the parser really does lag its source, and fail if any
word is torn across a block boundary. The existing reconcile harness gains the
new helper dependency.
…w rebuild

Review follow-ups for nesquena#7082:

- Maintainer should-fix: the no-cursor rebuild branch of
  _refreshTransparentFadeProseRow cleared the body and re-wrapped every
  word as .is-new, so the whole visible row dipped to opacity 0 and
  faded back (~620ms), and wholesale node replacement invited the
  one-time scroll-anchor bounce (nesquena#6257). Now the rendered text is
  snapshotted before the clear and the messages.js
  _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
  so only genuinely-new tail words animate. The helper is exported on
  window (__streamFadeMuteRenderedPrefix) the same way
  __anchorProseIncrementalNode is.

- Greptile P1: the same branch passed raw source text to
  _appendTransparentFadeText, flattening live markdown rows to literal
  syntax (links/emphasis/headings lost until settlement). The rebuild
  now adopts a deep clone of the candidate's parsed .msg-body (the
  incremental streaming-markdown output) when one exists, so the parsed
  DOM survives; the plain-text rebuild remains only as fallback for
  candidates without a parsed body. The resume cursor stays in source
  space when rendered text diverges from source (markdown), and shrinks
  to the rendered prefix for plain prose so the parser's held-back
  character is re-appended on the next delta instead of dropped.

Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
… markdown rebuild

Greptile P1 follow-up on the fade-row rebuild: when a cursorless live
markdown row adopts the incremental node's cloned DOM, that DOM lags the
source text by the streaming parser's held-back tail (pending/text
buffers). Keeping the full source-length resume cursor made the next
reconciliation append nothing, so the pending characters stayed missing
from the live row until settlement.

Read the held-back length from the parser bound on the candidate body
(_smdBindParserIdentity) and trim the source-space cursor by exactly
that length, so the cursor matches the adopted content and the next
reconciliation re-appends the pending tail.

Regression: rebuild while the parser holds an unrendered tail, then
reconcile again — the pending text must appear in the live row before
settlement.
Cursorless markdown rebuilds cloned the live parser tree, then later
source growth appended raw bytes through _appendTransparentFadeText.
Incomplete emphasis (Hello ** / Hello **world**) became literal
**world** while the candidate already had <strong>world</strong>.

Parser-owned candidates now keep reconciling from the parsed DOM.
Pending and MEDIA tails stay on the parser. Rendered-prefix mute is
unchanged.
After rewind/cache-miss the keyed visible row stayed distinct from the
persistent parser candidate, so every later growth frame deep-cloned the
parser DOM into the old row. That cut off the previous tail word's
~620ms fade and broke word-node identity.

Promote the parser-owned candidate into the existing row's DOM position
once, mute the already-rendered prefix on the real parser body, and
return the adopted node so later keyed renders hit existing === node.

Regression: two post-transition growth frames keep the first new tail
span as the same .is-new node until an explicit animationend.
@ruizanthony
ruizanthony force-pushed the fix/live-prose-fade-cursor-source-space branch from 30a0417 to 69e4fa3 Compare August 28, 2026 00:11
@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in 7d56234 Sep 8, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in exp-v0.52.278 — thank you @ruizanthony! 🎉

Live progress text in Transparent Stream no longer tears mid-word across block boundaries (Ces deu / x fichiers passent.). The renderer now adopts the parser-owned DOM once after a parser-owner replacement and handles the source-space cursor at block boundaries, keeping live prose whole.

Full gate on this crown-jewel streaming surface:

  • Codex: SAFE TO SHIP — no lost/duplicated/reordered text; per-token fast path retained (O(n) adoption only once after parser-owner replacement); live-to-final, MEDIA tails, reduced motion, multi-block, Unicode/CJK/emoji, prefix-dedupe all intact; base/head repro confirms the fix. 227 focused tests + 18 subtests pass.
  • Crown-jewel stream gate: GREEN across transparent_stream / compact_worklog / hide_all_activity — proseReversals=0, nonProseReanims=0, modeFlips=0. (The definitive motion proof for a live-render fix.)
  • Full suite: 15103 passed (two pre-existing box artifacts unrelated to this diff).

Credited via Co-authored-by in the release commit.

pull Bot pushed a commit to A-Archives-and-Forks/hermes-webui that referenced this pull request Sep 8, 2026
pull Bot pushed a commit to A-Archives-and-Forks/hermes-webui that referenced this pull request Sep 8, 2026
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
…w rebuild

Review follow-ups for nesquena#7082:

- Maintainer should-fix: the no-cursor rebuild branch of
  _refreshTransparentFadeProseRow cleared the body and re-wrapped every
  word as .is-new, so the whole visible row dipped to opacity 0 and
  faded back (~620ms), and wholesale node replacement invited the
  one-time scroll-anchor bounce (nesquena#6257). Now the rendered text is
  snapshotted before the clear and the messages.js
  _streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
  so only genuinely-new tail words animate. The helper is exported on
  window (__streamFadeMuteRenderedPrefix) the same way
  __anchorProseIncrementalNode is.

- Greptile P1: the same branch passed raw source text to
  _appendTransparentFadeText, flattening live markdown rows to literal
  syntax (links/emphasis/headings lost until settlement). The rebuild
  now adopts a deep clone of the candidate's parsed .msg-body (the
  incremental streaming-markdown output) when one exists, so the parsed
  DOM survives; the plain-text rebuild remains only as fallback for
  candidates without a parsed body. The resume cursor stays in source
  space when rendered text diverges from source (markdown), and shrinks
  to the rendered prefix for plain prose so the parser's held-back
  character is re-appended on the next delta instead of dropped.

Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
rodrigogs pushed a commit to rodrigogs/hermes-webui that referenced this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants