Skip to content

cmux-next agent pane: close the transcript gaps and stuck states left after #16426 - #16452

Merged
teamleaderleo merged 5 commits into
feat-cmux-nextfrom
feat-cmux-next-agent-pane-client-fixes
Oct 1, 2026
Merged

teamleaderleo merged 5 commits into
feat-cmux-nextfrom
feat-cmux-next-agent-pane-client-fixes

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

These are nine client fixes for the React agent pane, from the review notes collected in #16424.

Transcript and session state (direct.ts):

  • Lag recovery state: it keeps the live summary, queue and pending permission. It replays only mux events newer than the fetch cursor on top, so a missed permission_decision still clears the permission. loadOlder uses the same rebuildKeepingLiveState helper.
  • Failed lag fetch: the status changes to failed (or disconnected once the socket is gone) instead of staying at resyncing.
  • Reconnect gap: reconnecting to the same session fetches the gap between the old lastSeq and the re-attach page through _acpmux/events, instead of keeping only the newest 400 events. This and the next item only apply to clients created without onLost. The shipped pane passes onLost and builds a fresh client after a dropped socket, so today they cover the client's own reconnect path, not the app.
  • Missing session on reconnect: if the selected session is gone, the client bumps the selection generation, so a lag notice before the new attach can't page the whole history.
  • Watch lag: if a watch lag drops the selected session, the client falls back the same way a purge does. It only does this when the selection hasn't changed since the refresh was sent.
  • close(): it rejects pending requests instead of leaving them unsettled.
  • Failed prompt rows: they survive a rebuild.

Transcript view (App.tsx):

  • Height-only shrink: a pane whose rows first fit still opens at the latest row after it shrinks vertically.
  • register(): re-registering the same renderer with the same measure no longer re-renders the pane, so a register() call inside render code doesn't loop.

The bundled agent-pane/index.html is rebuilt.

Verification

  • bun test src/agent-session/acpmux/direct.test.ts src/agent-session/acpmux/transcript.test.tsx in webviews/:
    • At 7b570a0 (tests only): 24 pass, 9 fail. Each new test fails for its own reason; for example, the reconnect test is missing rows 7–9, and the height-shrink test reads scrollTop 0 where it expects 224.
    • At head: 33 pass.
  • bun run test (279 pass), typecheck and lint:ci pass.
  • build-agent-pane-web.sh --check and build-webviews-app.sh --check pass.
  • The changes are TypeScript only. There's no in-app dogfood against a live daemon.

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

Closes the transcript gaps and stuck states the cmux-next agent pane had from #16426, adds regression tests for each case, and rebuilds the bundled agent-pane/index.html.

Session state

  • Lag recovery keeps the live summary, queue, and permission, replaying only the missed mux events so a missed permission_decision still clears the permission.
  • A failed lag fetch shows failed (or disconnected once the socket is gone) instead of staying at resyncing.
  • Reconnecting to the same session fetches the events between the old cursor and the attach page, so nothing older than the newest 400 events is lost.
  • A reconnect that finds the selected session gone bumps the selection generation so a lag notice before the new attach can't page the whole history; watch lag that drops the selected session falls back the same way a purge does.
  • close() rejects pending requests, and failed prompt rows survive a rebuild.

Transcript view

  • A height-only shrink that makes fitting rows overflow still opens the pane at the latest row.
  • Re-registering with the same renderer and measure no longer re-renders the pane.

Written for commit b18e54b. Summary will update on new commits.

Review in cubic

teamleaderleo and others added 4 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
@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: 0a450620-8d79-452a-a860-eba420539d12

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 9497cbc: APPROVE. Checked against acpmux agent-gui (899e8e5):

  • Refactor: the reduce/reduceLiveState split is pure.
  • Replay: replay after the cursor skips nothing, and double-applied live events are idempotent in seq order.
  • Reconnect gap fetch: pagination and generation guards are correct, and it can't hit cursor_future.
  • Races: selectFallbackSession holds up against concurrent select, create and purge, and close() never double-rejects.
  • Tests: red-first reproduced (24 pass, 9 fail at 7b570a0); head passes 15 more runs with no flakes.

Non-blocking:

  • the reconnect fixes only cover clients without onLost (the body now says so);
  • a replayed status can override a newer session_changed status;
  • the reconnect gap fetch is uncapped;
  • a closed client emits once when close() rejects a pending send.

…ext-agent-pane-client-fixes

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html

@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.

5 issues found and verified against the latest diff

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/direct.test.ts">

<violation number="1" location="webviews/src/agent-session/acpmux/direct.test.ts:112">
P3: `dropAndReconnect` sleeps a fixed 300ms to outlast the client's first reconnect delay, which is currently 250ms in `scheduleReconnect` (direct.ts). The helper duplicates an internal constant, so changing `reconnectDelay` makes these three reconnect tests flaky or failing for a reason unrelated to the behavior under test. Wait for the actual reconnection instead: poll until `ScriptedSocket.current` is a new socket (bounded by a deadline), then drain microtasks.</violation>
</file>

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

<violation number="1" location="webviews/src/agent-session/acpmux/transcript.test.tsx:16">
P3: The mock's `disconnect()` never removes callbacks, so `resizeCallbacks` keeps one entry per mounted pane for the module's lifetime; `resize()` in later tests would then fire stale callbacks against unmounted roots (which is why this test must reset `resizeCallbacks.length = 0` defensively), and unmount no longer mirrors real `ResizeObserver` behavior. Make `disconnect()` remove its own callback and drop `resizeCallbacks.length = 0`.</violation>
</file>

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

<violation number="1" location="webviews/src/agent-session/acpmux/direct.ts:161">
P1: `AcpmuxApp` always supplies `onLost`, whose callback discards the old client and creates a new one with `lastSeq = 0`. The real pane therefore never fetches from its previous cursor; after a gap beyond the 400-event attach page, the replacement snapshot loses the previously visible transcript. Carry the cursor/history across the host handshake.</violation>

<violation number="2" location="webviews/src/agent-session/acpmux/direct.ts:277">
P2: This branch still publishes a stale watch result after the selection changes. A session created or selected while `_acpmux/watch` is pending can disappear from the picker; discard stale responses or merge them with the newer session list before emitting.</violation>

<violation number="3" location="webviews/src/agent-session/acpmux/direct.ts:351">
P2: Preserving a failed row while deleting its prompt correlation can render one accepted prompt twice after a socket loss. Keep the failed prompt correlation until a matching `user_message` settles it, or remove the local failed row when replay finds that prompt ID.</violation>
</file>

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

Re-trigger cubic

this.selectedSessionId = initialSession(this.selectedSessionId, this.sessions, this.host.newSession);
if (this.selectedSessionId) await this.attach(this.selectedSessionId);
// A reconnect to the same session keeps its transcript; the attach page holds only the newest events.
const resumeAfter = this.lastSeq;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: AcpmuxApp always supplies onLost, whose callback discards the old client and creates a new one with lastSeq = 0. The real pane therefore never fetches from its previous cursor; after a gap beyond the 400-event attach page, the replacement snapshot loses the previously visible transcript. Carry the cursor/history across the host handshake.

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 161:

<comment>`AcpmuxApp` always supplies `onLost`, whose callback discards the old client and creates a new one with `lastSeq = 0`. The real pane therefore never fetches from its previous cursor; after a gap beyond the 400-event attach page, the replacement snapshot loses the previously visible transcript. Carry the cursor/history across the host handshake.</comment>

<file context>
@@ -154,10 +153,19 @@ export class AcpmuxDirectClient {
       this.selectedSessionId = initialSession(this.selectedSessionId, this.sessions, this.host.newSession);
-      if (this.selectedSessionId) await this.attach(this.selectedSessionId);
+      // A reconnect to the same session keeps its transcript; the attach page holds only the newest events.
+      const resumeAfter = this.lastSeq;
+      const sessionId = this.selectedSessionId;
+      const generation = this.selectionGeneration;
</file context>

this.rows.clear(); for (const row of inFlight) this.rows.set(row.id, row); this.firstSeq = undefined; this.lastSeq = 0; this.turnOpen = false; this.streamingAssistant = undefined; this.streamingAssistantMessageId = undefined; this.streamingActivity = undefined; this.supersededMessageIds.clear(); this.messageRows.clear(); this.pendingPermission = undefined;
// A prompt still in flight keeps its optimistic row until an event settles it; a failed one stays to show it was not sent.
const inFlight = new Set(this.optimisticPromptRows.values());
const local = [...this.rows.values()].filter((row) => row.failed || inFlight.has(row.id));

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: Preserving a failed row while deleting its prompt correlation can render one accepted prompt twice after a socket loss. Keep the failed prompt correlation until a matching user_message settles it, or remove the local failed row when replay finds that prompt ID.

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 351:

<comment>Preserving a failed row while deleting its prompt correlation can render one accepted prompt twice after a socket loss. Keep the failed prompt correlation until a matching `user_message` settles it, or remove the local failed row when replay finds that prompt ID.</comment>

<file context>
@@ -324,13 +346,37 @@ export class AcpmuxDirectClient {
-    this.rows.clear(); for (const row of inFlight) this.rows.set(row.id, row); this.firstSeq = undefined; this.lastSeq = 0; this.turnOpen = false; this.streamingAssistant = undefined; this.streamingAssistantMessageId = undefined; this.streamingActivity = undefined; this.supersededMessageIds.clear(); this.messageRows.clear(); this.pendingPermission = undefined;
+    // A prompt still in flight keeps its optimistic row until an event settles it; a failed one stays to show it was not sent.
+    const inFlight = new Set(this.optimisticPromptRows.values());
+    const local = [...this.rows.values()].filter((row) => row.failed || inFlight.has(row.id));
+    this.rows.clear(); for (const row of local) this.rows.set(row.id, row); this.firstSeq = undefined; this.lastSeq = 0; this.turnOpen = false; this.streamingAssistant = undefined; this.streamingAssistantMessageId = undefined; this.streamingActivity = undefined; this.supersededMessageIds.clear(); this.messageRows.clear(); this.pendingPermission = undefined;
     const events = [...this.events].sort((a, b) => a.seq - b.seq);
</file context>

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");

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: This branch still publishes a stale watch result after the selection changes. A session created or selected while _acpmux/watch is pending can disappear from the picker; discard stale responses or merge them with the newer session list before emitting.

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 watch result after the selection changes. A session created or selected while `_acpmux/watch` is pending can disappear from the picker; discard stale responses or merge them with the newer session list before emitting.</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>

const dropAndReconnect = async () => {
const dropped = ScriptedSocket.current;
dropped.drop();
await new Promise((resolve) => setTimeout(resolve, 300));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: dropAndReconnect sleeps a fixed 300ms to outlast the client's first reconnect delay, which is currently 250ms in scheduleReconnect (direct.ts). The helper duplicates an internal constant, so changing reconnectDelay makes these three reconnect tests flaky or failing for a reason unrelated to the behavior under test. Wait for the actual reconnection instead: poll until ScriptedSocket.current is a new socket (bounded by a deadline), then drain microtasks.

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.test.ts, line 112:

<comment>`dropAndReconnect` sleeps a fixed 300ms to outlast the client's first reconnect delay, which is currently 250ms in `scheduleReconnect` (direct.ts). The helper duplicates an internal constant, so changing `reconnectDelay` makes these three reconnect tests flaky or failing for a reason unrelated to the behavior under test. Wait for the actual reconnection instead: poll until `ScriptedSocket.current` is a new socket (bounded by a deadline), then drain microtasks.</comment>

<file context>
@@ -91,12 +91,28 @@ class ScriptedSocket {
+const dropAndReconnect = async () => {
+  const dropped = ScriptedSocket.current;
+  dropped.drop();
+  await new Promise((resolve) => setTimeout(resolve, 300));
+  for (let pass = 0; pass < 5 && ScriptedSocket.current === dropped; pass += 1) await settle();
+  for (let pass = 0; pass < 5; pass += 1) await settle();
</file context>

navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement,
ResizeObserver: class { observe() {} disconnect() {} },
ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} disconnect() {} },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The mock's disconnect() never removes callbacks, so resizeCallbacks keeps one entry per mounted pane for the module's lifetime; resize() in later tests would then fire stale callbacks against unmounted roots (which is why this test must reset resizeCallbacks.length = 0 defensively), and unmount no longer mirrors real ResizeObserver behavior. Make disconnect() remove its own callback and drop resizeCallbacks.length = 0.

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/transcript.test.tsx, line 16:

<comment>The mock's `disconnect()` never removes callbacks, so `resizeCallbacks` keeps one entry per mounted pane for the module's lifetime; `resize()` in later tests would then fire stale callbacks against unmounted roots (which is why this test must reset `resizeCallbacks.length = 0` defensively), and unmount no longer mirrors real `ResizeObserver` behavior. Make `disconnect()` remove its own callback and drop `resizeCallbacks.length = 0`.</comment>

<file context>
@@ -5,13 +5,15 @@ import type { AcpmuxRow } from "./model";
   navigator: dom.window.navigator,
   HTMLElement: dom.window.HTMLElement,
-  ResizeObserver: class { observe() {} disconnect() {} },
+  ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} disconnect() {} },
   requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) as unknown as number,
   cancelAnimationFrame: (handle: number) => clearTimeout(handle),
</file context>
Suggested change
ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} disconnect() {} },
ResizeObserver: class { readonly callback: () => void; constructor(callback: () => void) { this.callback = callback; resizeCallbacks.push(callback); } observe() {} disconnect() { const index = resizeCallbacks.indexOf(this.callback); if (index !== -1) resizeCallbacks.splice(index, 1); } },

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Merging. b18e54b is the approved head 9497cbc with feat-cmux-next (#16433) merged in. The only conflict was the generated agent-pane/index.html, rebuilt with --check passing, and webviews tests (279 pass), typecheck and lint pass. All checks are green.

@teamleaderleo
teamleaderleo merged commit 305ca1e into feat-cmux-next Oct 1, 2026
58 checks passed
@teamleaderleo
teamleaderleo deleted the feat-cmux-next-agent-pane-client-fixes branch October 1, 2026 18:55
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Added commit 6f0984b7374f to keep session switching independent from stale detach cleanup.

  • A failed _acpmux/detach is treated as cleanup failure, then the requested session still attaches.
  • Re-selecting the same session skips the unnecessary detach/attach cycle.
  • Added a scripted regression covering the failed detach path.

Validation: bun test (280 pass), bun run typecheck, and bun run lint:ci pass in webviews/.

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