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 f10a25b27f10..8c2a0b9a05fd 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 @@
diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx
index d37a90c70edf..96719c8ce558 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); },
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/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[] {
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