Skip to content

cmux-next agent pane: no blank edge while flinging the transcript - #16454

Merged
teamleaderleo merged 7 commits into
feat-cmux-nextfrom
feat-cmux-next-agent-pane-scroll-lead
Oct 1, 2026
Merged

teamleaderleo merged 7 commits into
feat-cmux-nextfrom
feat-cmux-next-agent-pane-scroll-lead

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

While you flung the React agent pane's transcript, every frame showed a blank strip at the edge. In the #16231 bench, all 240 of 240 frames were blank. Two things caused it:

  • VirtualTranscript committed a scroll one animation frame after the scroll event.
  • It mounted only the viewport plus four rows of overscan, so the offset the next frame reached was never mounted.

Now the scroll handler commits from the scroll event with flushSync. The mounted range also leads in the scroll direction by two scroll steps, capped at four viewports, so the rows a fling reaches next are already in the DOM. When scrolling stops, the lead stays at the last step's size until the next scroll.

Part of #16424. Builds on #16452 (merged). This PR's commits are 16463ee (test) and 3b0dc6c (fix), plus a merge of feat-cmux-next.

Verification

  • transcript.test.tsx gets a jsdom test that scrolls up a viewport and a half per scroll event and expects the rows two steps ahead to be mounted right away:
    • Red at the test-only commit: min mounted top 8008, expected <= 5500, because rows stayed at the old offset.
    • Green at head.
  • In webviews/: bun run test (280 pass), typecheck and lint:ci pass. build-agent-pane-web.sh --check and build-webviews-app.sh --check pass.
  • In-app, tagged build nx-scroll-v1 (fleet job f5bbdb3b, head 3b0dc6c): seeded 5,000 rows, then ran four 3 s flings. The display is 160 Hz and the page runs at 80 Hz, as in the earlier benches.
per fling before (#16425) after
Blank edge frames 240–241 of 240–241 1 of 240–241 (the first frame, before any scroll delta)
Frame interval p50 / p95 12–13 / 13 ms 12–13 / 13 ms
Dropped frames 0, 1, 0, 0 0, 1, 0, 0
React render→commit p50 / p95 / max 1 / 1 / 2–4 ms 1 / 1–2 / 1–4 ms
Layout 0 ms 0 ms

The 80 Hz page cap remains (#16424).

Changelog

none

🤖 Generated with Claude Code

https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes the blank edge strip that showed on every frame when flinging the agent pane's transcript, and pulls in the feat-cmux-next lag and reconnect fixes.

Bug Fixes

  • Scroll now commits from the scroll event with flushSync, and the mounted range leads in the scroll direction by two scroll steps (capped at four viewports), so the rows a fling reaches next are already in the DOM.
  • Lag resync keeps the live summary, queue and permission through the rebuild and replays missed mux events on top.
  • A failed lag fetch ends "resyncing" with "failed" instead of staying stuck.
  • A reconnect re-attach fetches the events between the old cursor and the attach page; a lag notice before the new attach waits for it.
  • close() rejects pending requests, and failed optimistic prompt rows survive a rebuild.
  • A height-only shrink that makes fitting rows overflow opens at the latest row once; registering an unchanged renderer no longer re-renders the pane.

Written for commit 3d9a69c. Summary will update on new commits.

Review in cubic

teamleaderleo and others added 6 commits October 1, 2026 14:33
…ry gaps

Covers the #16424 follow-ups: lag resync dropping the live permission,
a failed lag fetch stuck on "resyncing", a reconnect that switches the
missing selection without bumping the generation, the event gap after a
reconnect re-attach, a watch lag dropping the selected session, close()
leaving requests pending, failed prompt rows lost on rebuild, the
open-at-latest jump on a height-only shrink, and register() re-rendering
for an unchanged renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD
- Lag resync keeps the live summary, queue and permission through the
  rebuild (shared with loadOlder) and replays the missed mux events on
  top, so a missed permission decision or status still lands.
- A failed lag fetch ends "resyncing" with "failed", or "disconnected"
  when the socket is gone.
- A connect that finds the selected session missing bumps the selection
  generation, so a lag notice before the new attach waits for it.
- A reconnect re-attach of the same session fetches the events between
  the old cursor and the attach page.
- A watch-lag refresh that no longer lists the selected session falls
  back to the most recent one, like a purge.
- close() rejects pending requests; onclose ignores a released socket.
- rebuild() keeps failed optimistic prompt rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD
… no-op registers

The open-at-latest effect also runs when the viewport height changes, so
rows that fit and then overflow after a vertical shrink still jump to the
latest row once. register() returns early when the kind already has that
renderer and measure, so a user renderer registering from an effect does
not loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD
… a scroll paints

VirtualTranscript commits a scroll one animation frame after the scroll
event and mounts only the viewport plus four rows of overscan. In the
#16231 fling bench every frame showed a blank edge. The test scrolls up a
viewport and a half per event and expects the rows two steps ahead to be
mounted synchronously; it fails with the rows still at the old offset
(min mounted top 8008, expected <= 5500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD
The scroll handler now commits the offset with flushSync from the scroll
event instead of a later animation frame, and the mounted range leads
in the scroll direction by two scroll steps (capped at four viewports),
so the rows a fling reaches next are already in the DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 21762a95-d369-4141-9f18-4394721ed058

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
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Subagent review at 3b0dc6c: APPROVE.

  • Lead direction: correct both ways, and a delta of 0 behaves as before.
  • flushSync: called from the scroll event handler, with no React warnings and no re-entrancy; layout-effect scrollTop writes queue their scroll events for the next frame.
  • Trackpad cost: browsers coalesce scroll events, so it's at most one synchronous commit per frame.
  • Anchoring: extra runs of the anchoring effect are no-ops while the layout is unchanged.
  • Test: discriminates both the rAF hop and a missing lead.

Non-blocking, added to #16424:

  • no downward-scroll test;
  • the lead stays after a big jump until the next scroll;
  • the anchor uses the first mounted row rather than the first visible one, which predates this PR and is slightly widened by the lead.

…ext-agent-pane-scroll-lead

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html
#	webviews/src/agent-session/acpmux/App.tsx
#	webviews/src/agent-session/acpmux/transcript.test.tsx

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="webviews/src/agent-session/acpmux/App.tsx">

<violation number="1" location="webviews/src/agent-session/acpmux/App.tsx:128">
P2: `scroll.delta` retains the last fling indefinitely, so later transcript updates keep mounting up to four extra viewports even after scrolling stops. Clear the directional lead after the scroll commit or scope it to the scroll render so virtualization returns to the normal range.</violation>
</file>

<file name="webviews/src/agent-session/acpmux/direct.ts">

<violation number="1" location="webviews/src/agent-session/acpmux/direct.ts:167">
P2: The reconnect path pages `_acpmux/events` past the attach snapshot, but with `replayLiveState=false` the fetched mux events' effects (queue, status, permission) are discarded: `rebuildKeepingLiveState(undefined)` restores the pre-fetch `summary`/`queue`/`pendingPermission` and replays nothing, and `apply()` then drops the eventStream notification for the same seq because `event.seq <= this.lastSeq`. Any `queued`/`queue_removed`/`status`/`permission_*` event that lands between the attach reply and the end of the fetch (the fetch pages to *now*, not to the attach page) is permanently lost from the live state, leaving a stale queue chip or status until an unrelated later event changes it. Pass `replayLiveState` as true (the lag path's behavior) so events after `resumeAfter` are replayed onto the attach snapshot in seq order; the guard `oldest > resumeAfter + 1` is computed only from transcript seqs, so it does not limit the mux events this fetch returns.</violation>

<violation number="2" location="webviews/src/agent-session/acpmux/direct.ts:277">
P2: This branch still publishes a stale session list when selection changes while `_acpmux/watch` is pending. Discard the response (or merge the current selection) before replacing `this.sessions`, otherwise the selected session can disappear from the picker until another watch update arrives.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Unanchored findings

2 findings could not be posted inline, so cubic included the feedback here instead.

1. webviews/src/agent-session/acpmux/direct.ts:277

P2: This branch still publishes a stale session list when selection changes while _acpmux/watch is pending. Discard the response (or merge the current selection) before replacing this.sessions, otherwise the selected session can disappear from the picker until another watch update arrives.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/direct.ts, line 277:

<comment>This branch still publishes a stale session list when selection changes while `_acpmux/watch` is pending. Discard the response (or merge the current selection) before replacing `this.sessions`, otherwise the selected session can disappear from the picker until another watch update arrives.</comment>

<file context>
@@ -236,28 +244,46 @@ export class AcpmuxDirectClient {
-    this.emit("session changed");
+    const missing = this.selectedSessionId !== undefined && !this.sessions.some((session) => session.sessionId === this.selectedSessionId);
+    if (missing && generation === this.selectionGeneration) this.selectFallbackSession("session changed");
+    else this.emit("session changed");
+  }
+
</file context>

2. webviews/src/agent-session/acpmux/direct.ts:167

P2: The reconnect path pages _acpmux/events past the attach snapshot, but with replayLiveState=false the fetched mux events' effects (queue, status, permission) are discarded: rebuildKeepingLiveState(undefined) restores the pre-fetch summary/queue/pendingPermission and replays nothing, and apply() then drops the eventStream notification for the same seq because event.seq <= this.lastSeq. Any queued/queue_removed/status/permission_* event that lands between the attach reply and the end of the fetch (the fetch pages to now, not to the attach page) is permanently lost from the live state, leaving a stale queue chip or status until an unrelated later event changes it. Pass replayLiveState as true (the lag path's behavior) so events after resumeAfter are replayed onto the attach snapshot in seq order; the guard oldest > resumeAfter + 1 is computed only from transcript seqs, so it does not limit the mux events this fetch returns.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/direct.ts, line 167:

<comment>The reconnect path pages `_acpmux/events` past the attach snapshot, but with `replayLiveState=false` the fetched mux events' effects (queue, status, permission) are discarded: `rebuildKeepingLiveState(undefined)` restores the pre-fetch `summary`/`queue`/`pendingPermission` and replays nothing, and `apply()` then drops the eventStream notification for the same seq because `event.seq <= this.lastSeq`. Any `queued`/`queue_removed`/`status`/`permission_*` event that lands between the attach reply and the end of the fetch (the fetch pages to *now*, not to the attach page) is permanently lost from the live state, leaving a stale queue chip or status until an unrelated later event changes it. Pass `replayLiveState` as true (the lag path's behavior) so events after `resumeAfter` are replayed onto the attach snapshot in seq order; the guard `oldest > resumeAfter + 1` is computed only from transcript seqs, so it does not limit the mux events this fetch returns.</comment>

<file context>
@@ -154,10 +153,19 @@ export class AcpmuxDirectClient {
+      if (sessionId) {
+        const page = await this.attach(sessionId, generation);
+        const oldest = page.length > 0 ? Math.min(...page.map((event) => event.seq)) : 0;
+        if (resumeAfter > 0 && oldest > resumeAfter + 1) await this.fetchMissedEvents(sessionId, generation, resumeAfter, false);
+      }
       this.hasConnected = true;
</file context>
if (resumeAfter > 0 && oldest > resumeAfter + 1) await this.fetchMissedEvents(sessionId, generation, resumeAfter);

const layout = measured.layout;
const reportedLayout = useRef<typeof measured | null>(null);
const range = visibleLayoutRange(layout, scrollTop, height);
const lead = Math.min(Math.abs(scroll.delta) * SCROLL_LEAD_STEPS, height * MAX_SCROLL_LEAD_VIEWPORTS);

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: scroll.delta retains the last fling indefinitely, so later transcript updates keep mounting up to four extra viewports even after scrolling stops. Clear the directional lead after the scroll commit or scope it to the scroll render so virtualization returns to the normal range.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/App.tsx, line 128:

<comment>`scroll.delta` retains the last fling indefinitely, so later transcript updates keep mounting up to four extra viewports even after scrolling stops. Clear the directional lead after the scroll commit or scope it to the scroll render so virtualization returns to the normal range.</comment>

<file context>
@@ -118,7 +125,8 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry =
   const layout = measured.layout;
   const reportedLayout = useRef<typeof measured | null>(null);
-  const range = visibleLayoutRange(layout, scrollTop, height);
+  const lead = Math.min(Math.abs(scroll.delta) * SCROLL_LEAD_STEPS, height * MAX_SCROLL_LEAD_VIEWPORTS);
+  const range = visibleLayoutRange(layout, scroll.delta < 0 ? scroll.top - lead : scroll.top, height + lead);
   useLayoutEffect(() => {
</file context>

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Merging. 3d9a69c is the approved head 3b0dc6c with feat-cmux-next (#16452) merged in. The conflicts were only this PR's own additions against base context plus the generated bundle, rebuilt with --check passing; webviews tests (280 pass), typecheck and lint pass. Release compile is green. cmux-next swift test is the lane's existing debt (cx-next-swift-tests).

@teamleaderleo
teamleaderleo merged commit f2d117b into feat-cmux-next Oct 1, 2026
53 of 54 checks passed
@teamleaderleo
teamleaderleo deleted the feat-cmux-next-agent-pane-scroll-lead branch October 1, 2026 19:04
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