conduit/lib/shared
cogwheel fdf89a8149
Fix streaming body strobing between content and loading skeleton (#540) (#541)
* Fix streaming body strobing between content and loading skeleton (#540)

Photosensitivity/seizure hazard: during a response the assistant body flashed at
~13Hz between the rendered markdown and the MarkdownLoadingSkeleton.

Root cause: StreamingMarkdownWidget let the loading skeleton (and a second
SizedBox.shrink blank path) REPLACE already-rendered content. When the turn phase
latches to completed mid-response (responseDone gap / status+tool phases), the
body is treated as non-streaming while answer tokens keep arriving; each
non-streaming content growth left the compiled document stale-but-valid during
the async recompile, and the gate regressed to the skeleton instead of keeping
the rendered content.

Make the skeleton/blank strictly a first-paint state: once a compiled document
exists it is always rendered (a stale doc lags at most one frame and is
superseded by _applyCompiledDocumentState), so the skeleton can only appear when
there is no renderable content yet. Removed the now-dead
_preserveStaleCompiledDocumentUntilFreshFinal field.

Adds a deterministic regression test (delayed compile service) reproducing the
non-streaming content-growth stale window; fails before the fix (skeleton over
rendered content), passes after.

* Clear stale content on scope change so a version switch can't show the old body (#541 review)

Macroscope + Greptile flagged that the #540 fix ('always render a non-null doc')
lets a reused StreamingMarkdownWidget show the previous message/version's content
while the new body compiles asynchronously. Fix without re-introducing the #540
strobe: thread a clearStaleDocument signal so a stateScopeId change (a new
message/version) clears the stale document — the skeleton covers the gap — while
same-scope content growth keeps its document. Added a regression test (fails
before: the previous version's text lingers on a scope switch).

* Don't make a stale streaming document selectable (#541 review)

Macroscope: the #540 fix removed the blank path that previously prevented a
stale (non-streaming, not-yet-fresh) document from being wrapped in SelectionArea.
During the responseDone gap the body is non-streaming while content still grows,
so a stale doc was made selectable — re-enabling the concurrent-modification
crash in Flutter's selection system. Gate SelectionArea on hasFreshCompiledDocument
so a stale doc renders (no #540 flash) but isn't selectable until the compile
settles. Extended the regression test to assert it.

* Clear stale streaming document on scope change (#541 review)

Greptile: the scope-change clear only ran on the non-streaming path. A scope
change while streaming (e.g. switching from an old version back to the live,
still-streaming current) kept the previous scope's compiled document visible
until the deferred refresh landed, flashing a prior message under the new scope.

Extend the existing clearDocumentWhenAsync mechanism to resolveStreamingPrepared
(same async-only clear, so cached/sync compiles never flash), thread it through
_resolveCompiledDocument's streaming branch, and carry the scope change into the
deferred streaming refresh via _pendingClearStaleDocument so only the first
post-scope-change refresh clears (same-scope growth still keeps its doc, #540).
Adds a regression test for the deferred streaming scope-change path.

(cherry picked from commit 11d75779fcab4c0fb3e338f8d4d4b96c7d950448)

* Clear the stale streaming document immediately on scope change (#541 review)

CodeRabbit + Greptile: the deferred streaming scope-change path only armed a
pending clear (_pendingClearStaleDocument), consumed later by the scheduled
refresh. build() now keeps rendering any existing compiled document while
streaming (#540), so the previous scope painted for one frame under the new
stateScopeId before the deferred refresh landed — exactly the flash this guards.

Clear synchronously in didUpdateWidget instead: add
MarkdownDocumentController.clearDocument() (cancels in-flight resolves, nulls the
document, notifies) and call it on the deferred-path scope change; the scheduled
refresh then compiles the new content. Removes the _pendingClearStaleDocument
flag machinery entirely. The sync streaming and non-streaming paths already clear
synchronously via clearDocumentWhenAsync, so all scope-change clears now happen
within didUpdateWidget — no first-frame stale paint on any path.

clearDocument() always invalidates pending async resolves first (even when no
document is rendered yet) so a stale compile started under the old scope can't
land after the clear. Test asserts the FIRST post-switch frame is already clear,
that the new body stays absent until the compile is released (proving a genuine
async gap), and pins the harness key so the switch exercises didUpdateWidget
reuse rather than a remount. Verified: the first-frame assertion fails with the
deferred behaviour, passes with the immediate clear.

(cherry picked from commit 2798fe542d9477fe1fc3a4f518a2aac549282390)
2026-06-30 21:06:30 +05:30
..
services Offline-first persistence with Drift (#508) 2026-06-19 15:08:16 +05:30
theme fix(ui): enhance system UI overlay style handling across multiple pages 2026-06-06 15:05:43 +05:30
utils fix(security): restrict external link launching to safe schemes 2026-06-10 20:51:15 +05:30
widgets Fix streaming body strobing between content and loading skeleton (#540) (#541) 2026-06-30 21:06:30 +05:30