From 7b570a0db620e9f893672a2688c7f8c4ee01cae2 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 14:33:15 -0400 Subject: [PATCH 1/4] cmux-next agent pane: add failing tests for lag, reconnect and registry 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 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../src/agent-session/acpmux/direct.test.ts | 121 ++++++++++++++++++ .../agent-session/acpmux/transcript.test.tsx | 67 +++++++++- 2 files changed, 186 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.test.ts b/webviews/src/agent-session/acpmux/direct.test.ts index 88ff23edad95..612a9457ed7b 100644 --- a/webviews/src/agent-session/acpmux/direct.test.ts +++ b/webviews/src/agent-session/acpmux/direct.test.ts @@ -91,12 +91,28 @@ class ScriptedSocket { const [request] = this.waiting.splice(index, 1); this.reply(request!, result); } + /// Answers a held request with a JSON-RPC error. + fail(method: string) { + const index = this.waiting.findIndex((request) => request.method === method); + if (index < 0) throw new Error(`no ${method} request is waiting`); + const [request] = this.waiting.splice(index, 1); + this.onmessage?.({ data: JSON.stringify({ id: request!.id, error: { message: `${method} failed` } }) }); + } notify(method: string, params: unknown) { this.onmessage?.({ data: JSON.stringify({ jsonrpc: "2.0", method, params }) }); } close() { this.readyState = 3; } + drop() { this.readyState = 3; this.onclose?.(); } } const userEvent = (sessionId: string, seq: number, text: string): EventRecord => ({ sessionId, seq, at: seq, dir: "mux", kind: "user_message", msg: { text } }); const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +/// Drops the socket and waits out the client's first reconnect delay. +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(); +}; describe("direct client session state", () => { const realSocket = globalThis.WebSocket; @@ -292,4 +308,109 @@ describe("direct client session state", () => { expect(await selected).toBe("b"); expect(texts()).toEqual(["b one"]); }); + + test("lag recovery keeps the live summary, queue and permission", async () => { + await connect(); + ScriptedSocket.current.notify("_acpmux/permission_pending", { sessionId: "a", permissionId: "p1", request: { toolCall: { title: "Run" }, options: [] } }); + ScriptedSocket.respond = ({ method, params }) => method === "_acpmux/events" && params.afterSeq === 6 ? { events: [userEvent("a", 7, "a seven")] } : {}; + ScriptedSocket.current.notify("_acpmux/lagged", { sessionIds: ["a"], watch: false, dropped: 1 }); + await settle(); + expect(texts()).toEqual(["a five", "a six", "a seven"]); + expect(latest().permission?.permissionId).toBe("p1"); + expect(latest().queue).toEqual([{ id: "q1", prompt: "queued" }]); + expect(latest().summary?.status).toBe("idle"); + }); + + test("lag recovery still applies a missed permission decision and status", async () => { + await connect(); + ScriptedSocket.current.notify("_acpmux/permission_pending", { sessionId: "a", permissionId: "p1", request: { toolCall: { title: "Run" }, options: [] } }); + ScriptedSocket.respond = ({ method }) => method === "_acpmux/events" ? { events: [{ sessionId: "a", seq: 7, at: 7, dir: "mux", kind: "permission_decision", msg: {} }, { sessionId: "a", seq: 8, at: 8, dir: "mux", kind: "status", msg: { status: "running" } }] } : {}; + ScriptedSocket.current.notify("_acpmux/lagged", { sessionIds: ["a"], watch: false, dropped: 2 }); + await settle(); + expect(latest().permission).toBeUndefined(); + expect(latest().summary?.status).toBe("running"); + }); + + test("a failed lag fetch does not leave the pane resyncing", async () => { + await connect(); + ScriptedSocket.held.add("_acpmux/events"); + ScriptedSocket.current.notify("_acpmux/lagged", { sessionIds: ["a"], watch: false, dropped: 1 }); + expect(latest().connection).toBe("resyncing"); + ScriptedSocket.current.fail("_acpmux/events"); + await settle(); + expect(latest().connection).toBe("failed"); + }); + + test("a reconnect that finds the selected session gone waits for the new attach before a lag resync", async () => { + await connect(); + ScriptedSocket.respond = ({ method, params }) => { + if (method === "_acpmux/watch") return { sessions: [{ sessionId: "b" }] }; + if (method === "_acpmux/attach") return attachReply(params.sessionId); + return {}; + }; + ScriptedSocket.held.add("_acpmux/attach"); + await dropAndReconnect(); + expect(ScriptedSocket.current.waiting.find((request) => request.method === "_acpmux/attach")?.params.sessionId).toBe("b"); + ScriptedSocket.current.notify("_acpmux/lagged", { dropped: 1 }); + await settle(); + expect(ScriptedSocket.current.sent.some((request) => request.method === "_acpmux/events")).toBe(false); + ScriptedSocket.current.release("_acpmux/attach", attachReply("b")); + await settle(); + expect(latest().sessionId).toBe("b"); + expect(texts()).toEqual(["b one"]); + }); + + test("a reconnect re-attach fetches the events between the old cursor and the new attach page", async () => { + await connect(); + ScriptedSocket.respond = ({ method, params }) => { + if (method === "_acpmux/watch") return { sessions: [{ sessionId: "a" }, { sessionId: "b" }] }; + if (method === "_acpmux/attach") return { session: { sessionId: "a", status: "idle" }, events: [userEvent("a", 10, "a ten"), userEvent("a", 11, "a eleven")] }; + if (method === "_acpmux/events" && params.afterSeq === 6) return { events: [userEvent("a", 7, "a seven"), userEvent("a", 8, "a eight")], more: true }; + if (method === "_acpmux/events" && params.afterSeq === 8) return { events: [userEvent("a", 9, "a nine"), userEvent("a", 10, "a ten")], more: false }; + return {}; + }; + await dropAndReconnect(); + expect(texts()).toEqual(["a five", "a six", "a seven", "a eight", "a nine", "a ten", "a eleven"]); + }); + + test("a watch lag that drops the selected session selects the most recent remaining one", async () => { + await connect(); + ScriptedSocket.respond = ({ method, params }) => { + if (method === "_acpmux/watch") return { sessions: [{ sessionId: "b" }] }; + if (method === "_acpmux/attach") return attachReply(params.sessionId); + return {}; + }; + ScriptedSocket.current.notify("_acpmux/lagged", { sessionIds: [], watch: true, dropped: 4 }); + await settle(); + await settle(); + expect(latest().sessionId).toBe("b"); + expect(texts()).toEqual(["b one"]); + expect(latest().queue).toEqual([]); + }); + + test("closing the client settles its pending requests", async () => { + const client = await connect(); + ScriptedSocket.held.add("session/set_model"); + const outcome = Promise.race([ + client.setModel("m").then(() => "resolved", () => "rejected"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 100)), + ]); + await settle(); + client.close(); + expect(await outcome).toBe("rejected"); + }); + + test("a failed prompt row survives a lag rebuild", async () => { + const client = await connect(); + ScriptedSocket.held.add("session/prompt"); + const sending = client.send("did not send").catch(() => "failed"); + await settle(); + ScriptedSocket.current.fail("session/prompt"); + expect(await sending).toBe("failed"); + ScriptedSocket.respond = ({ method, params }) => method === "_acpmux/events" && params.afterSeq === 6 ? { events: [userEvent("a", 7, "a seven")] } : {}; + ScriptedSocket.current.notify("_acpmux/lagged", { sessionIds: ["a"], watch: false, dropped: 1 }); + await settle(); + expect(texts()).toEqual(["a five", "a six", "a seven", "did not send"]); + expect(latest().rows.find((row) => row.text === "did not send")?.failed).toBe(true); + }); }); diff --git a/webviews/src/agent-session/acpmux/transcript.test.tsx b/webviews/src/agent-session/acpmux/transcript.test.tsx index 8a24ab6d2810..b6cb3c21f93a 100644 --- a/webviews/src/agent-session/acpmux/transcript.test.tsx +++ b/webviews/src/agent-session/acpmux/transcript.test.tsx @@ -5,13 +5,15 @@ import type { AcpmuxRow } from "./model"; // A silent console: jsdom has no canvas, so text measurement logs and falls back to row estimates. const dom = new JSDOM("
", { pretendToBeVisual: true, virtualConsole: new VirtualConsole() }); const globals = globalThis as Record; +/// Every ResizeObserver callback, so a test can report a viewport resize. +const resizeCallbacks: (() => void)[] = []; const saved = Object.fromEntries(["window", "document", "navigator", "HTMLElement", "ResizeObserver", "requestAnimationFrame", "cancelAnimationFrame", "IS_REACT_ACT_ENVIRONMENT"].map((key) => [key, globals[key]])); Object.assign(globals, { window: dom.window, document: dom.window.document, 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), IS_REACT_ACT_ENVIRONMENT: true, @@ -20,9 +22,20 @@ afterAll(() => Object.assign(globals, saved)); const { act, createElement } = await import("react").then((react) => ({ act: react.act, createElement: react.createElement })); const { createRoot } = await import("react-dom/client"); -const { VirtualTranscript } = await import("./App"); +const { AcpmuxApp, VirtualTranscript } = await import("./App"); const { acpmuxPerf } = await import("./perf"); +/// jsdom does no layout: give the transcript scroller a scriptable viewport and scroll offset. +function fakeViewport(size: { width: number; height: number }) { + const prototype = dom.window.HTMLElement.prototype; + const offsets = new WeakMap(); + const isScroller = (node: HTMLElement) => node.classList.contains("acpmux-scroll"); + Object.defineProperty(prototype, "clientHeight", { configurable: true, get(this: HTMLElement) { return isScroller(this) ? size.height : 0; } }); + Object.defineProperty(prototype, "clientWidth", { configurable: true, get(this: HTMLElement) { return isScroller(this) ? size.width : 0; } }); + Object.defineProperty(prototype, "scrollTop", { configurable: true, get(this: HTMLElement) { return offsets.get(this) ?? 0; }, set(this: HTMLElement, value: number) { offsets.set(this, value); } }); + return () => { for (const key of ["clientHeight", "clientWidth", "scrollTop"]) delete (prototype as unknown as Record)[key]; }; +} + const rows: AcpmuxRow[] = Array.from({ length: 200 }, (_, index) => ({ id: `row-${index}`, version: 1, at: index, kind: index % 2 ? "assistant" : "user", text: `message ${index}` })); describe("acpmux virtual transcript", () => { @@ -51,4 +64,54 @@ describe("acpmux virtual transcript", () => { acpmuxPerf.enabled = false; } }); + + test("a height-only shrink that makes fitting rows overflow opens at the latest row once", async () => { + const size = { width: 760, height: 10_000 }; + const restore = fakeViewport(size); + resizeCallbacks.length = 0; + const root = createRoot(dom.window.document.getElementById("root")!); + const fewRows = rows.slice(0, 6); + const render = () => root.render(createElement(VirtualTranscript, { rows: fewRows, onToggleActivity: () => {}, expanded: new Set() })); + const resize = (height: number) => act(async () => { size.height = height; for (const callback of resizeCallbacks) callback(); }); + try { + await act(async () => render()); + const scroller = dom.window.document.querySelector(".acpmux-scroll") as HTMLElement; + const totalHeight = parseFloat((dom.window.document.querySelector(".acpmux-spacer") as HTMLElement).style.height); + expect(scroller.scrollTop).toBe(0); + expect(totalHeight).toBeGreaterThan(40); + await resize(40); + expect(scroller.scrollTop).toBe(totalHeight - 40); + // After that one jump a reader who scrolled up stays put through later resizes. + scroller.scrollTop = 0; + await resize(30); + expect(scroller.scrollTop).toBe(0); + } finally { + await act(async () => root.unmount()); + restore(); + } + }); +}); + +describe("acpmux renderer registry", () => { + test("registering the same renderer again does not re-render the pane", async () => { + const root = createRoot(dom.window.document.getElementById("root")!); + const host = dom.window as unknown as Window; + let firstRenders = 0; + let secondRenders = 0; + const FirstChips = () => { firstRenders += 1; return null; }; + const SecondChips = () => { secondRenders += 1; return null; }; + try { + await act(async () => root.render(createElement(AcpmuxApp))); + await act(async () => host.cmuxAcpmuxRegistry!.register("composerChips", FirstChips as never)); + const afterRegister = firstRenders; + expect(afterRegister).toBeGreaterThan(0); + await act(async () => host.cmuxAcpmuxRegistry!.register("composerChips", FirstChips as never)); + expect(firstRenders).toBe(afterRegister); + await act(async () => host.cmuxAcpmuxRegistry!.register("composerChips", SecondChips as never)); + expect(secondRenders).toBeGreaterThan(0); + } finally { + await act(async () => root.unmount()); + delete (host as unknown as Record).cmuxAcpmuxRegistry; + } + }); }); From 508f7dc896d5337a2ac4b2981075b8f8b3440a6e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 14:34:51 -0400 Subject: [PATCH 2/4] cmux-next agent pane: close the direct client's lag and reconnect gaps - 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 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- webviews/src/agent-session/acpmux/direct.ts | 110 +++++++++++++------- 1 file changed, 74 insertions(+), 36 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 1450fb208458..a571c02bbc6a 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -138,8 +138,7 @@ export class AcpmuxDirectClient { socket.onclose = () => { if (this.socket !== socket) return; if (!opened) { this.opening = false; reject(new Error("acpmux WebSocket closed before connect")); return; } - for (const request of this.pending.values()) request.reject(new Error("acpmux WebSocket closed")); - this.pending.clear(); + this.rejectPending(); this.emit("disconnected"); if (!this.hasConnected || this.closed) return; if (this.onLost) { const onLost = this.onLost; this.close(); onLost(); } else this.scheduleReconnect(); @@ -154,10 +153,19 @@ export class AcpmuxDirectClient { this.catalog = normalizeCatalog(harnesses); if (this.selectedSessionId && !this.sessions.some((session) => session.sessionId === this.selectedSessionId)) { this.selectedSessionId = this.sessions[0]?.sessionId; + this.selectionGeneration += 1; this.resetSessionState(); } 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; + 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; this.reconnectDelay = 250; this.emit("connected"); @@ -236,17 +244,22 @@ export class AcpmuxDirectClient { if (!sessionId || this.attachedGeneration !== this.selectionGeneration) return; if (Array.isArray(params?.sessionIds) && !params.sessionIds.map(String).includes(sessionId)) return; this.emit("resyncing"); - void this.fetchMissedEvents(sessionId, this.selectionGeneration, this.lastSeq).catch(() => undefined); + const generation = this.selectionGeneration; + void this.fetchMissedEvents(sessionId, generation, this.lastSeq).catch(() => { + if (!this.closed && generation === this.selectionGeneration) this.emit(this.socket?.readyState === WebSocket.OPEN ? "failed" : "disconnected"); + }); } /// Pages from its own cursor: live events keep advancing lastSeq meanwhile. - private async fetchMissedEvents(sessionId: string, generation: number, afterSeq: number): Promise { + /// The live summary, queue and permission survive the rebuild; after a lag the + /// missed events are replayed onto them, while a fresh attach is already current. + private async fetchMissedEvents(sessionId: string, generation: number, afterSeq: number, replayLiveState = true): Promise { for (let cursor = afterSeq; ;) { const result = await this.request("_acpmux/events", { sessionId, afterSeq: cursor, limit: 5_000 }); if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return; const missed: EventRecord[] = result?.events ?? []; this.events = mergeEventRecords(this.events, missed); - this.rebuild(); + this.rebuildKeepingLiveState(replayLiveState ? cursor : undefined); if (result?.more !== true || missed.length === 0) break; cursor = Math.max(cursor, ...missed.map((event) => event.seq)); } @@ -254,10 +267,23 @@ export class AcpmuxDirectClient { } /// session_changed notices dropped by a watch lag: reread the whole list. + /// A selection made while the request was out is newer than the list. private async refreshSessions(): Promise { + const generation = this.selectionGeneration; const watched = await this.request("_acpmux/watch", { enabled: true }); this.sessions = (watched?.sessions ?? []).filter((session: Session) => session.sessionId); - 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"); + } + + /// The selected session is gone: show the most recent remaining one, or none. + private selectFallbackSession(reason: string): void { + this.selectedSessionId = this.sessions[0]?.sessionId; + const generation = ++this.selectionGeneration; + this.resetSessionState(); + this.emit(reason); + if (this.selectedSessionId) void this.attach(this.selectedSessionId, generation).catch(() => undefined); } private request(method: string, params: Record): Promise { @@ -269,32 +295,28 @@ export class AcpmuxDirectClient { }); } - private async attach(sessionId: string, generation = this.selectionGeneration): Promise { - if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return; + /// Returns the attach page's events, or none when the selection moved on. + private async attach(sessionId: string, generation = this.selectionGeneration): Promise { + if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return []; const result = await this.request("_acpmux/attach", { sessionId, limit: 400, kinds: ["transcript"], eventStream: true }); - if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return; + if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return []; + const page: EventRecord[] = result?.events ?? []; const detail = result?.session ?? {}; this.summary = detail; this.queue = (detail.queue ?? []).map((entry: any) => ({ id: String(entry.promptId), prompt: String(entry.prompt ?? "") })); - this.events = mergeEventRecords(result?.events ?? [], this.events); + this.events = mergeEventRecords(page, this.events); this.rebuild(); this.attachedGeneration = generation; this.emit("attached"); + return page; } private sessionChanged(params: any): void { const session = params?.session; if (params?.kind === "purged" && session?.sessionId) { this.sessions = this.sessions.filter((item) => item.sessionId !== session.sessionId); - if (session.sessionId === this.selectedSessionId) { - this.selectedSessionId = this.sessions[0]?.sessionId; - const generation = ++this.selectionGeneration; - this.resetSessionState(); - this.emit("session purged"); - if (this.selectedSessionId) void this.attach(this.selectedSessionId, generation).catch(() => undefined); - } else { - this.emit("session purged"); - } + if (session.sessionId === this.selectedSessionId) this.selectFallbackSession("session purged"); + else this.emit("session purged"); return; } if (!session?.sessionId) return; @@ -324,13 +346,37 @@ export class AcpmuxDirectClient { } private rebuild(): void { - // A prompt still in flight keeps its optimistic row until an event settles it. - const inFlight = [...this.optimisticPromptRows.values()].flatMap((rowId) => this.rows.get(rowId) ?? []); - 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); for (const event of events) { this.lastSeq = Math.max(this.lastSeq, event.seq); this.firstSeq = this.firstSeq === undefined ? event.seq : Math.min(this.firstSeq, event.seq); this.reduce(event); } } + /// rebuild() replays a partial event window, so keep the live summary, queue and + /// permission; events after replayAfterSeq are then reapplied on top of them. + private rebuildKeepingLiveState(replayAfterSeq?: number): void { + const summary = this.summary; + const queue = this.queue; + const permission = this.pendingPermission; + this.rebuild(); + this.summary = summary; + this.queue = queue; + this.pendingPermission = permission; + if (replayAfterSeq !== undefined) for (const event of this.events) if (event.seq > replayAfterSeq && event.dir === "mux") this.reduceLiveState(event); + } + + /// Mux events that move the live summary, queue or permission. + private reduceLiveState(event: EventRecord): void { + const msg = event.msg ?? {}; + if (event.kind === "queued" || event.kind === "queue_updated") { const id = String(msg.promptId ?? ""); if (id) this.queue = [...this.queue.filter((entry) => entry.id !== id), { id, prompt: String(msg.text ?? "") }]; } + else if (event.kind === "queue_removed" || event.kind === "dequeued") this.queue = this.queue.filter((entry) => entry.id !== String(msg.promptId ?? "")); + else if (event.kind === "permission_request") this.applyPermission({ ...msg, sessionId: event.sessionId }); + else if (event.kind === "permission_decision") this.pendingPermission = undefined; + else if (event.kind === "status") this.summary = { ...this.summary, status: msg.status }; + } + private reduce(event: EventRecord): void { const msg = event.msg ?? {}; const update = sessionUpdate(event); @@ -352,11 +398,7 @@ export class AcpmuxDirectClient { } } else if (event.kind === "turn_end" || event.kind === "turn_result") { this.turnOpen = false; if (this.streamingAssistant) { const row = this.rows.get(this.streamingAssistant); if (row) { row.streaming = false; row.version += 1; } } this.rows.delete("typing"); if (event.kind === "turn_result") this.rows.set(`summary-${event.seq}`, { id: `summary-${event.seq}`, version: 1, at: event.at, kind: "turnSummary", durationMs: undefined, toolCount: [...this.rows.values()].filter((row) => row.kind === "activity").length, status: String(msg.status ?? "completed"), error: msg.errorText }); this.streamingAssistant = undefined; this.streamingAssistantMessageId = undefined; this.streamingActivity = undefined; } - else if (event.kind === "queued" || event.kind === "queue_updated") { const id = String(msg.promptId ?? ""); if (id) this.queue = [...this.queue.filter((entry) => entry.id !== id), { id, prompt: String(msg.text ?? "") }]; } - else if (event.kind === "queue_removed" || event.kind === "dequeued") this.queue = this.queue.filter((entry) => entry.id !== String(msg.promptId ?? "")); - else if (event.kind === "permission_request") this.applyPermission({ ...msg, sessionId: event.sessionId }); - else if (event.kind === "permission_decision") this.pendingPermission = undefined; - else if (event.kind === "status") this.summary = { ...this.summary, status: msg.status }; + else this.reduceLiveState(event); return; } if (!update) return; @@ -429,19 +471,15 @@ export class AcpmuxDirectClient { const generation = this.selectionGeneration; const result = await this.request("_acpmux/events", { sessionId, beforeSeq: this.firstSeq, limit: 400, kinds: ["transcript"] }); if (generation !== this.selectionGeneration || this.selectedSessionId !== sessionId) return; - const summary = this.summary; - const queue = this.queue; - const permission = this.pendingPermission; const older: EventRecord[] = result?.events ?? []; this.events = mergeEventRecords(older, this.events); - this.rebuild(); - this.summary = summary; - this.queue = queue; - this.pendingPermission = permission; + this.rebuildKeepingLiveState(); this.historyExhausted = result?.more === false || older.length === 0 || (this.firstSeq ?? 1) <= 1; this.emit("history"); } - close(): void { this.closed = true; if (this.reconnectTimer !== undefined) window.clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.socket?.close(); this.socket = undefined; } + /// The socket's own onclose ignores a socket close() already let go of, so settle requests here. + close(): void { this.closed = true; if (this.reconnectTimer !== undefined) window.clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.socket?.close(); this.socket = undefined; this.rejectPending(); } + private rejectPending(): void { for (const request of this.pending.values()) request.reject(new Error("acpmux WebSocket closed")); this.pending.clear(); } } function normalizeCatalog(value: any): any[] { From e1fa4cdb874d39d38867250faa43f9ab86396063 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 14:34:52 -0400 Subject: [PATCH 3/4] cmux-next agent pane: open at latest on a height-only shrink and skip 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 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- webviews/src/agent-session/acpmux/App.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index b7e6ccb235e0..8c91699f3d54 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -137,6 +137,7 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry = const delta = layout.tops[range.first] - old.tops[range.first]; if (Math.abs(delta) > 0.5) node.scrollTop += delta; } + // Runs on height too: rows that fit and then overflow on a height-only shrink keep the same memoized layout. if (!didOpenAtLatest.current && node && layout.totalHeight > node.clientHeight) { const latest = Math.max(0, layout.totalHeight - node.clientHeight); node.scrollTop = latest; @@ -144,7 +145,7 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry = didOpenAtLatest.current = true; } previousLayout.current = layout; - }, [layout, range.first]); + }, [layout, range.first, height]); const scheduleScroll = useRef(null); const onScroll = (event: React.UIEvent) => { const next = event.currentTarget.scrollTop; if (scheduleScroll.current !== null) return; scheduleScroll.current = requestAnimationFrame(() => { scheduleScroll.current = null; setScrollTop(next); }); }; return
{rows.slice(range.first, range.last).map((row, index) => { const absoluteIndex = range.first + index; const kind = rowKind(row); const Component = registry[kind] ?? NoticeRow; const rendered = ; return
{Component.measure || defaultRegistry[kind] ? rendered : setMeasuredHeights((current) => { if (current.get(row.id) === value) return current; const next = new Map(current); next.set(row.id, value); return next; })}>{rendered}}
; })}
; @@ -162,7 +163,7 @@ export function AcpmuxApp() { const directClient = useRef(undefined); useEffect(() => { window.React = React; - window.cmuxAcpmuxRegistry = { register(kind, renderer, options) { if (options?.measure) renderer.measure = options.measure; (window.cmuxAcpmuxRegistry as unknown as Record)[kind] = renderer; setRegistry(currentRegistry()); }, configure() { setRegistry(currentRegistry()); } }; + window.cmuxAcpmuxRegistry = { register(kind, renderer, options) { const registered = window.cmuxAcpmuxRegistry as unknown as Record; if (registered[kind] === renderer && (!options?.measure || options.measure === renderer.measure)) return; if (options?.measure) renderer.measure = options.measure; registered[kind] = renderer; setRegistry(currentRegistry()); }, configure() { setRegistry(currentRegistry()); } }; window.cmuxAcpmuxBridge = { receive(next) { if (next.protocolVersion !== 1) return; const change = diffRows(rowsRef.current, next.rows); rowsRef.current = new Map(next.rows.map((row) => [row.id, row])); setSnapshot(next); void change; }, applyTheme(theme) { applyAgentTheme(theme as never); }, From 9497cbcdd55f165df6cb3e2054fc7a2e3df2e691 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 1 Oct 2026 14:34:59 -0400 Subject: [PATCH 4/4] cmux-next agent pane: rebuild the bundled pane Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RYQHfug1ZVQDp4eWgwVUtD --- .../Resources/agent-pane/index.html | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html index 2cb1f58fb274..2c183e9220c8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html @@ -2001,20 +2001,20 @@