Skip to content

fix(fence): stop three ways a fence could stay a code block - #423

Open
lws2004 wants to merge 1 commit into
omdsh-dev:mainfrom
lws2004:fix/fence-discovery
Open

lws2004 wants to merge 1 commit into
omdsh-dev:mainfrom
lws2004:fix/fence-discovery

Conversation

@lws2004

@lws2004 lws2004 commented Nov 5, 2026

Copy link
Copy Markdown

What

Three independent defects could leave a dsh-ui fence stuck in its stock code-block form. Each is observable either on a transcript rendered in one pass (switching sessions) or while a reply is still streaming.

1. Dirty-target overflow silently dropped discovery work

markDirty returns early once dirtyTargets reaches DIRTY_TARGET_LIMIT (512). A session switch renders the whole transcript at once, so the cap is hit almost immediately. Every fence after that depends on rotationSweep, which only scans ROTATION_ROWS (24) rows every ROTATION_TICKS × SWEEP_MS (5s).

Measured on a real 7-fence transcript (ego lite, DSH 0.2.1-rc.3):

t=139    b=7  g=0     <- 7 code blocks rendered, 0 fences taken over
t=902    g=1          <- +763ms
t=5901   g=3          <- +5000ms
t=10902  g=4          <- +5000ms
t=15901  g=5          <- +5000ms

The 5000ms cadence is exactly ROTATION_TICKS × SWEEP_MS.

2. Snapshot language was read by DOM ordinal

fences[index] assumes the DOM code-block order matches the snapshot fence order. That breaks when the snapshot lags behind the DOM, when the two sides filter differently (reasoning / tool rows), or when one message is split into several text blocks by tool calls. A miss yields undefined, and renderBlock returns early for a streaming fence with an unknown language — so the fence waits for the reply to settle before content-based detection can take over.

3. Streaming unmounted the mount at component boundaries

if (mount.skeleton && !settled) only kept the mount while it was still a skeleton. After the first mount produced a real component, mount.skeleton was false, so any intermediate body that parsed to null hit unmountBlock — the raw code block reappeared — and the next chunk mounted again. The visible effect is a code block that "just sits there" for the whole streaming phase.

How

  • The overflow path sets overflowRescan; the next sweep runs one full findFenceCandidates() pass, same scope as the initial scan. A single linear query covers the transcript in tens of milliseconds.
  • New sameFenceBody / resolveFenceByBody match a fence by body prefix, falling back to the ordinal. Exact body equality still wins first, so the common path is unchanged.
  • if (!settled) keeps the already-rendered content (skeleton or last good tree) until the fence settles, instead of flashing the code block back.

Verification

  • tsc -p tsconfig.json — clean
  • tsdown — all bundles build
  • scripts/check-standalone.mjs / scripts/check-map-assets.mjs — pass
  • vitest run — 413 pre-existing environment failures before and after this change (1693 passing either way, 31 files failing on both sides). They are jsdom/localStorage setup issues in this environment, unrelated to dom-fence.
  • End-to-end, same 7-fence session switch: 105 / 109 / 117 ms across three runs, down from 35s.

Scope

Only src/client/dom-fence.tsx (+61 / −9). No build artifacts (upstream gitignores lib/), no dependency changes, no new tests.

Happy to split this into three PRs if smaller changes are preferred.

Three independent defects could leave a fence stuck in its stock code-block
form. Each one is reproducible on a transcript that is rendered all at once
(a session switch), or while a reply is still streaming.

1. markDirty dropped the node once dirtyTargets reached its 512-entry cap.
   A session switch renders the whole transcript in one pass, so the cap is
   hit almost immediately and the remaining fences fell through to the
   rotation pass — which only scans 24 rows every 5 seconds. Measured on a
   7-fence transcript: every fence took 35s to appear. The overflow now sets
   a flag and the next sweep runs one full discovery pass, which is a single
   linear query and covers the transcript in tens of milliseconds.

2. Fence language was read from the snapshot by DOM ordinal (fences[index]).
   The two lists desynchronise when the snapshot lags behind the DOM, when
   the two sides filter differently, or when one message is split into
   several text blocks. A miss returns undefined, and a streaming fence with
   an unknown language is not taken over until the reply settles. Match by
   body prefix first and keep the ordinal as the fallback.

3. During streaming, a body that momentarily parsed to no complete component
   unmounted the mount and restored the raw code block. Once the first mount
   had produced a real component, mount.skeleton was false, so every
   component boundary flashed the code block back and the fence appeared to
   "sit there" while the reply streamed. Keep whatever is already rendered
   until the fence settles.

Verification: tsc clean, tsdown builds, check-standalone and check-map-assets
pass. The vitest suite reports the same 413 pre-existing environment failures
before and after this change (1693 passing either way), so no new failures.

This branch has not been deployed

No deployments
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.

1 participant