Skip to content

fix(web): stop the transcript scrolling itself on every streamed chunk - #4884

Closed
AsimNet wants to merge 1 commit into
pingdotgg:mainfrom
AsimNet:pr/calmer-scrolling
Closed

fix(web): stop the transcript scrolling itself on every streamed chunk#4884
AsimNet wants to merge 1 commit into
pingdotgg:mainfrom
AsimNet:pr/calmer-scrolling

Conversation

@AsimNet

@AsimNet AsimNet commented Jul 29, 2026

Copy link
Copy Markdown

The chat transcript follows the live edge while an agent streams, which fires a scroll once per chunk. Over a long session that continuous movement is tiring to read, and there is no way to turn it off.

How it's fixed

Two per-device client settings in Settings → Appearance:

  • chatAutoScroll gates live-follow, defaulting to off. The transcript holds still while output streams; the send-time anchor still positions the new turn once, so you are not left looking at old messages; and the scroll-to-end pill becomes the way back to the edge.
  • reduceMotion forces the reduced-motion path on regardless of the OS preference — scrolls become instant instead of animated, and the looping indicator animations stop (status pulse, skeleton shimmer, working indicator, ultrathink gradients).

Two details worth review attention:

The pill's visibility guard needed the setting too. It treats "left the live edge" as the list settling rather than the reader navigating — which is only true while something is actually following. Without that, turning follow off hid the pill exactly when it became the only way back.

Spinners are deliberately left spinning under reduce-motion. A frozen spinner reads as a hung app, which is worse than the motion it removes.

Verification

tsgo --noEmit, vp test run --project unit (1666 tests), vp lint, and vp build all pass on this branch against current main. MessagesTimeline.test.tsx gains a case asserting the list only re-pins to the end when the setting is on.

Screenshots

The new rows in Settings → Appearance:

Appearance settings

This change is behavioural rather than visual — the difference is motion during streaming, which a still image can't show. Happy to record a short clip if that's wanted before review.


Written by Claude Opus 4.5 in Claude Code.


Note

Medium Risk
Default-off live-follow changes behavior for all users during streaming, and scroll-mode/pill logic in ChatView is easy to regress; changes are UI-only with no auth or data impact.

Overview
Behavior change: streaming transcripts no longer auto-follow the live edge by default. A new chatAutoScroll client setting (default off) gates per-chunk live-follow in ChatView and LegendList maintainScrollAtEnd in MessagesTimeline. Send-time turn anchoring still runs once; the scroll-to-end pill is shown when the reader is behind the edge, with settling guards updated so the pill is not suppressed when follow is off.

Adds reduceMotion (default off) plus a shared prefersReducedMotion helper and data-reduce-motion on <html> so scroll jumps (anchor, pill, minimap), draft-hero / mobile composer transitions, and looping status animations respect the setting or OS prefers-reduced-motion (spinners stay animated).

Settings → Appearance exposes both toggles, restore-defaults, and user docs. Chat timeline tests assert list re-pin only when auto-scroll is enabled. Minor RTL-oriented class swaps (ps/ms/start, force-ltr on tool output).

Reviewed by Cursor Bugbot for commit c376c94. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Stop transcript auto-scrolling on every streamed chunk with a new "Follow agent output" setting

  • Adds chatAutoScroll and reduceMotion boolean client settings (both defaulting to false) in settings.ts; when chatAutoScroll is off the message list no longer re-pins to the end during streaming.
  • Introduces a settle window (LIVE_FOLLOW_SETTLE_MS) in ChatView.tsx to suppress the scroll-to-end pill from flashing during thread switches and post-send transitions.
  • Adds a prefersReducedMotion utility in reducedMotion.ts that checks both the new setting and the OS prefers-reduced-motion media query; mirrors the result to html[data-reduce-motion] for CSS.
  • CSS in index.css disables continuous animations (pulses, shimmers, view transitions) when reduced motion is active.
  • Exposes both settings as toggles in the Appearance settings panel in SettingsPanels.tsx.
  • Behavioral Change: existing users default to chatAutoScroll: false, meaning auto-follow of streaming output is off by default for all users.

Macroscope summarized c376c94.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e27f51e1-f80a-46b5-bb71-7a05ef03fe43

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 29, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One finding: a leftover temporary Effect script (packages/contracts/tmp-rexec.ts) was committed alongside the reduce-motion/auto-scroll change. The rest of the changed TypeScript (contracts schema additions, reducedMotion.ts, timeline/settings wiring) follows the Effect conventions — no service, layer, or error-model violations found.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/tmp-rexec.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks like a leftover debugging bridge rather than part of the reduce-motion / auto-scroll change, and it lands in a shared package at a non-canonical location: it sits outside packages/contracts/src, so the package tsconfig.json (include: ["src"]) never typechecks it, and it hardcodes a private host plus several as any casts. Suggest dropping it from this PR.

If it does need to live in the repo, it would be worth moving it to a typechecked scripts location and tidying the Effect usage while it is still small: import * as RpcClient from "effect/unstable/rpc/RpcClient" instead of the barrel named import, Effect.provide(protocolLayer) instead of manually Layer.build-ing the context, and a typed failure instead of Effect.catchCause((cause) => Effect.logDebug(String(cause))), which swallows every failure and defect into a stringified message.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/tmp-rexec.ts Outdated
let printedUpTo = 0;
let streaming = false;

const onChunk = (chunk: string) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium contracts/tmp-rexec.ts:89

onChunk appends every terminal byte to buf and never trims data that has already been written to stdout. A long-running or high-output remote command causes the process to retain the command's entire output for up to the one-hour default timeout; sufficiently large build/test output exhausts memory and crashes the helper. Once the consumed prefix has been written to stdout, it should be discarded from buf, keeping only the small suffix needed to detect a split END marker.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/tmp-rexec.ts around line 89:

`onChunk` appends every terminal byte to `buf` and never trims data that has already been written to stdout. A long-running or high-output remote command causes the process to retain the command's entire output for up to the one-hour default timeout; sufficiently large build/test output exhausts memory and crashes the helper. Once the consumed prefix has been written to stdout, it should be discarded from `buf`, keeping only the small suffix needed to detect a split `END` marker.

Comment thread packages/contracts/tmp-rexec.ts Outdated
Comment on lines +149 to +155
Effect.runPromise(Effect.scoped(program) as any).then(
(code) => process.exit(Number(code)),
(err) => {
console.error("\n[rexec] failed:", err?.message ?? err);
process.exit(1);
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium contracts/tmp-rexec.ts:149

process.exit() is called immediately after writing the final stdout chunk, which can silently truncate output when stdout is piped and the write is still queued. Node forces termination on process.exit() regardless of pending stream writes, so the exit code is reported correctly but the command's output is incomplete. Set process.exitCode and let the event loop drain naturally, or await process.stdout before exiting.

Suggested change
Effect.runPromise(Effect.scoped(program) as any).then(
(code) => process.exit(Number(code)),
(err) => {
console.error("\n[rexec] failed:", err?.message ?? err);
process.exit(1);
},
);
Effect.runPromise(Effect.scoped(program) as any).then(
(code) => {
process.exitCode = Number(code);
},
(err) => {
console.error("\n[rexec] failed:", err?.message ?? err);
process.exitCode = 1;
},
);
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/tmp-rexec.ts around lines 149-155:

`process.exit()` is called immediately after writing the final stdout chunk, which can silently truncate output when stdout is piped and the write is still queued. Node forces termination on `process.exit()` regardless of pending stream writes, so the exit code is reported correctly but the command's output is incomplete. Set `process.exitCode` and let the event loop drain naturally, or await `process.stdout` before exiting.

Comment thread packages/contracts/tmp-rexec.ts Outdated
else if (argv[i] === "--") (cmdParts.push(...argv.slice(i + 1)), (i = argv.length));
else cmdParts.push(argv[i]!);
}
const command = cmdParts.join(" ");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium contracts/tmp-rexec.ts:33

cmdParts.join(" ") discards argument boundaries, so any argument containing spaces is split into separate words on the remote shell. For example, -- printf '%s\n' 'hello world' sends printf %s\n hello world, making hello and world separate arguments instead of one. Similarly, -- bash -lc 'echo one; echo two' no longer passes the script as a single -c argument, changing the command's semantics. Any argument with whitespace, glob characters, or other shell-significant text executes incorrectly unless the caller manually adds literal shell quoting that a normal shell would have stripped. Consider joining with proper shell escaping (e.g. shlex-style quoting) instead of a plain space.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/tmp-rexec.ts around line 33:

`cmdParts.join(" ")` discards argument boundaries, so any argument containing spaces is split into separate words on the remote shell. For example, `-- printf '%s\n' 'hello world'` sends `printf %s\n hello world`, making `hello` and `world` separate arguments instead of one. Similarly, `-- bash -lc 'echo one; echo two'` no longer passes the script as a single `-c` argument, changing the command's semantics. Any argument with whitespace, glob characters, or other shell-significant text executes incorrectly unless the caller manually adds literal shell quoting that a normal shell would have stripped. Consider joining with proper shell escaping (e.g. `shlex`-style quoting) instead of a plain space.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8c01c834d2a4cd4db2335ce667da9c8fc53b561c. Configure here.

Comment thread packages/contracts/tmp-rexec.ts Outdated
Comment thread apps/web/src/components/ChatView.tsx Outdated
Comment thread apps/web/src/components/ChatView.tsx Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

3 blocking correctness issues found. This PR introduces new user-facing settings and changes the default transcript scrolling behavior from on to off. Additionally, open review comments identify potential CSS issues with undefined variables and incomplete animation coverage that should be verified before merging.

You can customize Macroscope's approvability policy. Learn more.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c01c834d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/src/components/ChatView.tsx Outdated
: // --safe-area-inset-start/end resolve to the physical side that
// matches the reading direction, so the notch inset lands on the
// right edge in RTL instead of following the LTR assumption.
"workspace-topbar ps-[calc(var(--safe-area-inset-start)+0.75rem)] pe-[calc(var(--safe-area-inset-end)+0.75rem)] sm:ps-[calc(var(--safe-area-inset-start)+1.25rem)] sm:pe-[calc(var(--safe-area-inset-end)+1.25rem)]",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Define the logical safe-area variables before using them

On every non-Electron chat surface, these padding declarations are invalid because neither --safe-area-inset-start nor --safe-area-inset-end is defined anywhere in the repository. Browsers therefore discard all four declarations, removing the previous 0.75rem/1.25rem horizontal header padding and placing its controls against the viewport edges; define the variables or retain declarations based on the existing env(safe-area-inset-*) values.

Useful? React with 👍 / 👎.

Comment thread apps/web/src/index.css Outdated
Comment on lines +1524 to +1528
html[data-reduce-motion="true"] [class*="animate-status-pulse"],
html[data-reduce-motion="true"] [class*="animate-status-ping"],
html[data-reduce-motion="true"] [class*="animate-sidebar-working-text"],
html[data-reduce-motion="true"] [class*="animate-skeleton"],
html[data-reduce-motion="true"] [class*="animate-pulse"] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Disable the provider-update bounce in Reduce motion mode

When the in-app Reduce motion switch is enabled without the OS media preference, this selector list does not match the infinite [animation:bounce_2.4s_ease-in-out_infinite] on ProviderInstanceCard.tsx:626; its motion-reduce:animate-none only responds to the OS preference. Consequently the newly advertised setting still leaves a continuously repainting indicator bouncing, so include that animation in the data-reduce-motion path.

AGENTS.md reference: AGENTS.md:L128-L134

Useful? React with 👍 / 👎.

Comment thread packages/contracts/tmp-rexec.ts Outdated
Comment on lines +18 to +20
const BASE = process.env.T3_BASE ?? "http://100.77.192.114:3773";
const TOKEN = process.env.T3_TOKEN ?? "";
const THREAD_ID = process.env.T3_THREAD ?? "claude-remote-build";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove the temporary remote-execution bridge

This commit adds an unreferenced maintainer scratch utility that opens a terminal and executes arbitrary shell commands against a hard-coded private T3 endpoint. It is unrelated to the web scrolling change, embeds environment-specific identifiers in distributed source, and introduces runtime logic into the contracts package; remove it from the commit rather than shipping it as supported repository code.

AGENTS.md reference: AGENTS.md:L119-L124

Useful? React with 👍 / 👎.

The chat transcript follows the live edge while an agent streams, which fires a
scroll once per chunk. Over a long session that continuous movement is tiring to
read, and there was no way to turn it off.

Adds two per-device settings. `chatAutoScroll` gates live-follow and defaults to
off: the transcript holds still, the send-time anchor still positions the new
turn once, and the scroll-to-end pill becomes the way back. `reduceMotion` forces
the reduced-motion path on regardless of the OS preference, making scrolls
instant and stopping the looping indicator animations that otherwise never stop
repainting while an agent works.

The pill's visibility guard needed care rather than a simple gate. Leaving the
live edge without a user gesture means a programmatic scroll is still settling,
not that the reader moved: with follow on, live-follow owns the position for as
long as it holds it; with it off, only the window just after a thread switch or a
send is settling, because LegendList reports not-at-end while initialScrollAtEnd
lands. Suppressing indefinitely in that mode would hide the pill exactly when it
is the only way back, and not suppressing at all would flash it on every thread
switch.

Reduce motion also covers the provider-update indicator, whose arbitrary
[animation:bounce_...] class the animate-* selectors never matched and whose
motion-reduce: variant only answers to the OS preference.

Spinners are deliberately left spinning — a frozen spinner reads as a hung app.

Written by Claude Opus 4.5 in Claude Code.
@AsimNet
AsimNet force-pushed the pr/calmer-scrolling branch from 8c01c83 to c376c94 Compare July 29, 2026 23:47
@AsimNet

AsimNet commented Jul 29, 2026

Copy link
Copy Markdown
Author

Thanks — all four findings were real. Fixed and force-pushed as one clean commit.

packages/contracts/tmp-rexec.ts — dropped. Correct call: it was a scratch remote-exec script that a git add -A swept in, unrelated to this change, outside src so the package never typechecked it, and it hardcoded a private tailnet IP. It is gone from the branch; the follow-on notes about the buffer never being trimmed, process.exit() truncating piped stdout, and join(" ") losing argument boundaries were all fair, and moot now that the file is out.

Undefined --safe-area-inset-start/end — real bug, and my fault for how I split this work. Those logical variables and their [dir="rtl"] overrides belong to a separate RTL change; carving this PR out brought the use without the definition, so the four declarations were invalid and the header lost its horizontal padding on mobile web. Reverted to the original env(safe-area-inset-left/right) form. Good catch — a browser silently dropping an invalid calc() is exactly the kind of thing that survives a typecheck.

Scroll pill flashing on thread switch — also real, and the fix needed more than the gate I had. Leaving the live edge without a user gesture means a programmatic scroll is still settling rather than the reader moving away. With follow on, live-follow owns the position for as long as it holds it. With it off, only the window just after a thread switch or a send is settling, because LegendList reports not-at-end while initialScrollAtEnd lands. So suppression is now keyed on both: the existing generation match plus, when follow is off, a short settle window stamped whenever live-follow takes the position. Suppressing indefinitely in that mode would hide the pill exactly when it is the only way back; not suppressing at all would flash it on every switch.

Bouncing provider-update indicator — right, and it undercut the whole point of the setting. ProviderInstanceCard uses an arbitrary [animation:bounce_...] class, which the animate-* selectors never matched, and its motion-reduce: variant only answers to the OS preference. Both the media-query and data-reduce-motion paths now cover it. Verified in the built stylesheet rather than the source, since the minifier escapes the colon in that selector.

tsgo --noEmit, 1666 unit tests, vp lint and vp build all pass on the updated branch.

@juliusmarminge

Copy link
Copy Markdown
Member

Closing as part of the open-PR backlog sweep (wave 1).

Reason: Jul conflicted transcript auto-scroll; streaming list virtualization landed later.
Related: #9688

Reopen if this is still wanted and you’re willing to rebase onto current main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants