Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix chat scroll jitter during streaming; respect reduced motion - #11

Merged
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion
Jul 9, 2026
Merged

Fix chat scroll jitter during streaming; respect reduced motion#11
kateebonner merged 1 commit into
local/amicodefrom
kate/chat-jitter-motion

Conversation

@kateebonner

Copy link
Copy Markdown
Contributor

Why

The chat visibly jitters/bounces while a response streams, and occasionally jumps on its own even when idle.

Root cause: the message timeline had three independent systems all writing scrollTop to the bottom on the same content update, each reading scroll geometry at a slightly different instant from a virtua list whose item heights are estimates. When virtua corrects a measured height a frame later, the writers have already landed on slightly different positions → visible bounce.

What changed (3 files)

1. Gate auto-scroll on real streaming statepackages/app/src/pages/session.tsx
createAutoScroll({ working: () => true }) → real sync.data.session_working(id). The auto-scroller's ResizeObserver was force-following the bottom on any reflow (image load, tool accordion expand, font swap, layout settle), which read as the chat "jumping" on its own. Now it only follows while the model is actually streaming.
Send-to-bottom and the jump-to-bottom button are unaffected — they use the force path, which bypasses this gate.

2. Stop the estimate-based realign from fighting the real-DOM lockpackages/app/src/pages/session/message-timeline.tsx
virtua.scrollToIndex(align:"end") (which uses estimated offsets) was called on every streamed token, landing a hair off from the rAF loop that pins against the real DOM height — that disagreement was the frame-to-frame bounce. Now scrollToIndex only runs when the row set changes (new message/tool row) or session status flips; pure intra-row text growth is left entirely to the measured-bottom rAF lock.
Safe because timelineRowKeys is memoized with equals: sameKeys, so its reference only changes on a real row change.

3. Respect prefers-reduced-motion in JSpackages/ui/src/components/message-part.tsx
The CSS honors reduced motion everywhere, but the imperative animate() reveal in ShellSubmessage didn't — and its initial render collapses width to 0 / blurs the value. Under reduced motion it now snaps to the resting state instead of animating (and can't get stuck hidden).

Verification

  • bun run typecheckapp and ui both clean.
  • Unit tests — message-part, scroll-view, layout-scroll, file-tab-scroll, use-session-hash-scroll: 16 pass / 0 fail.
  • ⚠️Not covered here: the perceptual smoothness of streaming scroll needs a visual pass against a live model — not drivable headless. Changes are low-risk and reversible, but eyes on a real stream is the real acceptance test.

Deliberately deferred (happy to follow up)

  • Fuller scroll consolidation — collapse to a single bottom-follow authority (CSS overflow-anchor + flex bottom-anchoring, delete the rAF workaround for virtua #301). Higher impact but needs the visual pass to tune.
  • Positive motion pass — a subtle standardized enter (transform+opacity) for new message blocks; move the header spinner's width/margin animation to a compositor-only transform.

🤖 Generated with Claude Code

The message timeline had three independent systems writing scrollTop to
the bottom on the same content update, reading scroll geometry at slightly
different instants from a virtualizer whose item heights are estimates.
When virtua corrected a measured height a frame later, the writers had
already landed on slightly different positions -> visible bounce.
- session.tsx: gate createAutoScroll on the session's real working state
instead of a hard-coded `true`. The auto-scroller's ResizeObserver was
force-following the bottom on *any* reflow (image load, accordion expand,
font swap), which read as the chat jumping on its own. Send-to-bottom and
the jump button are unaffected (they use the force path).
- message-timeline.tsx: only realign via virtua's estimate-based
scrollToIndex(align:"end") when the row set changes or status flips, not
on every streamed token. Pure intra-row growth is left to the
measured-bottom rAF lock, which pins against the real DOM height, so the
two mechanisms stop disagreeing frame-to-frame. Safe because
timelineRowKeys is memoized with `equals: sameKeys`.
- message-part.tsx: honor prefers-reduced-motion in the imperative
ShellSubmessage reveal (the CSS already does; this JS animate() did not,
and its initial render collapses width to 0 / blurs the value).
Verified: app + ui typecheck clean; message-part, scroll-view,
layout-scroll, file-tab-scroll, use-session-hash-scroll unit tests pass
(16/16). Perceptual smoothness during live streaming still wants a visual
pass against a real model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kateebonner
kateebonner merged commit 8129912 into local/amicodeJul 9, 2026
0 of 4 checks passed
jeonghun-jj-lee added a commit that referenced this pull request Aug 7, 2026
…les (#115) (#126)
Extends the global editing fallback (patch #11) to cover select-all, undo,
and redo — the same class of bug where native browser actions are suppressed
by the VS Code/Electron platform layer inside the sandboxed iframe.
- Cmd+A: selectAll via .select() (form fields) or Range.selectNodeContents
(contenteditables)
- Cmd+Z: document.execCommand('undo')
- Cmd+Shift+Z / Cmd+Y: document.execCommand('redo')
- Framed-context guard preserved (unframed windows keep native behavior)
- Existing Cmd+V/C/X behavior unchanged
- Shift modifier now allowed specifically for Cmd+Shift+Z (redo chord)
Closes#115
Sign up for freeto 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

@kateebonner