diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx
index 0cf8464e3a2a..c75f8ab15f12 100644
--- a/webviews/src/agent-session/acpmux/App.tsx
+++ b/webviews/src/agent-session/acpmux/App.tsx
@@ -65,6 +65,7 @@ import {
isLineRow,
isLiveStep,
restRoom,
+ linkUp,
} from "./model";
import { lacksImageBytes, ownQueue, queuedAttachments, queuedMessageRows } from "./conversation/queuedMessages";
import { isVisiblyEmptyMessage } from "./conversation/visibleMessage";
@@ -6166,6 +6167,9 @@ function AcpmuxPane() {
const handoffLabels = useMemo(() => handoffStrings(t), [t]);
const checkpointLabels = useMemo(() => checkpointStrings(t), [t]);
const [checkpointVariant, setCheckpointVariant] = useState<"compact" | "expanded">("compact");
+ // Footer actions and checkpoints show only while acpmux is reachable. The client reports failures
+ // in the transcript; a bridge that cannot route an action has nothing to add.
+ const connected = linkUp(snapshot.connection);
const checkpoints = useCheckpoints({
request: callNative,
target: snapshot.summary?.cwd
@@ -6175,7 +6179,7 @@ function AcpmuxPane() {
hostKind: snapshot.summary.hostKind,
}
: undefined,
- online: snapshot.connection === "connected",
+ online: connected,
strings: checkpointLabels,
variant: checkpointVariant,
});
@@ -6234,9 +6238,6 @@ function AcpmuxPane() {
};
const revealRef = useRef(reveal);
revealRef.current = reveal;
- // Footer actions show only while acpmux is reachable. The client reports failures in the
- // transcript; a bridge that cannot route an action has nothing to add.
- const connected = snapshot.connection !== "disconnected" && !snapshot.connection.startsWith("connecting");
const forkable = canFork(snapshot);
const forkSeq = latestForkSeq(snapshot.rows);
const forkAny = forksAnyTurn(snapshot.summary?.family);
@@ -8944,7 +8945,9 @@ function AcpmuxPane() {
!!snapshot.unavailable,
snapshot.sessionId,
snapshot.rows.length,
- snapshot.connection === "connected",
+ // An open that failed on a socket still open (direct.ts emits "failed") read no chat: its
+ // machine may be paused, so recovery reads it as it does a lost link.
+ connected && snapshot.connection !== "failed",
freshView,
),
recheck: recheckCloudChat,
diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts
index 1b322654dcc2..3191f550c2c3 100644
--- a/webviews/src/agent-session/acpmux/model.ts
+++ b/webviews/src/agent-session/acpmux/model.ts
@@ -764,6 +764,14 @@ export type AcpmuxWorkflowWire = {
* (`notListedAtOpen`), or it left the list while the pane showed it (`leftListWhileShown`). */
export type MissingSessionReason = "notListedAtOpen" | "leftListWhileShown";
+/// Whether the pane's link to acpmux is up. A snapshot's `connection` names why the client emitted
+/// it ("connected", "session changed", a streamed event's kind, "failed" for a refused request on
+/// an open socket), not the link's state: only a lost ("disconnected") or opening ("connecting…")
+/// connection is down. Never compare it with "connected" to ask whether the link is up.
+export function linkUp(connection: string): boolean {
+ return connection !== "disconnected" && !connection.startsWith("connecting");
+}
+
export type AcpmuxSnapshot = {
type: "snapshot";
/** The selected chat's daemon-owned retry, including the last automatic attempt. */
diff --git a/webviews/test/agent-pane-link-state.test.tsx b/webviews/test/agent-pane-link-state.test.tsx
new file mode 100644
index 000000000000..ee4407f5354e
--- /dev/null
+++ b/webviews/test/agent-pane-link-state.test.tsx
@@ -0,0 +1,208 @@
+// l10n-allow-file: test fixture rows, not shipped UI.
+// The pane's checkpoints read whether the link to acpmux is up from the snapshot's `connection`,
+// which names why the client last emitted. A read of `=== "connected"` took every later emit
+// ("session changed", a streamed event's kind) for a lost link and marked checkpoints unsupported.
+import { afterAll, expect, test } from "bun:test";
+import { JSDOM, VirtualConsole } from "jsdom";
+
+const dom = new JSDOM("
", {
+ url: "cmux-agent://pane/index.html",
+ pretendToBeVisual: true,
+ virtualConsole: new VirtualConsole(),
+});
+const scope = globalThis as Record;
+const replacements: Record = {
+ window: dom.window,
+ document: dom.window.document,
+ location: dom.window.location,
+ navigator: dom.window.navigator,
+ HTMLElement: dom.window.HTMLElement,
+ Element: dom.window.Element,
+ Node: dom.window.Node,
+ Event: dom.window.Event,
+ KeyboardEvent: dom.window.KeyboardEvent,
+ MouseEvent: dom.window.MouseEvent,
+ customElements: dom.window.customElements,
+ MutationObserver: dom.window.MutationObserver,
+ ResizeObserver: class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ IntersectionObserver: class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
+ requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
+ cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
+ matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
+ IS_REACT_ACT_ENVIRONMENT: true,
+};
+for (const key of Object.getOwnPropertyNames(dom.window))
+ if (/^(HTML|SVG|CSS|Shadow|Document|Mutation)/.test(key) && !(key in scope))
+ replacements[key] = (dom.window as unknown as Record)[key];
+const saved = Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const);
+Object.assign(scope, replacements);
+(dom.window as unknown as { matchMedia: unknown }).matchMedia = replacements.matchMedia;
+// jsdom has no layout. A tall viewport mounts every row of this small chat.
+Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get: () => 10_000 });
+Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get: () => 760 });
+
+const { act } = await import("react");
+const { createRoot } = await import("react-dom/client");
+const fixtures = await import("../src/gallery/fixtures/acpmux");
+const { AcpmuxApp } = await import("../src/agent-session/acpmux/App");
+
+afterAll(() => {
+ dom.window.close();
+ for (const [key, had, value] of saved) {
+ if (had) scope[key] = value;
+ else delete scope[key];
+ }
+});
+
+type Snapshot = import("../src/agent-session/acpmux/model").AcpmuxSnapshot;
+const window_ = dom.window as unknown as {
+ webkit: unknown;
+ cmuxAcpmuxBridge?: { receive(value: unknown): void };
+};
+
+/// The pane on a local chat in a folder whose repository takes checkpoints. The app's bridge
+/// answers `ready`, `git.capabilities` says checkpoints are served, and each
+/// `pane.checkpointAvailability` the pane sends is recorded.
+async function pane() {
+ const { assistant, chat, user } = fixtures;
+ const snapshot = chat([user("Fix the build", 3, { id: "u1" }), assistant("Fixed.", 2.8, { id: "r1" })]);
+ const availability: boolean[] = [];
+ window_.webkit = {
+ messageHandlers: {
+ agentSession: {
+ postMessage: (message: { method?: string; params?: Record }) => {
+ if (message.method === "ready") {
+ setTimeout(() => window_.cmuxAcpmuxBridge?.receive(structuredClone(snapshot)), 0);
+ return Promise.resolve({
+ ok: true,
+ value: { protocolVersion: 1, transport: "preview", machineName: "Mac" },
+ });
+ }
+ if (message.method === "git.capabilities") return Promise.resolve({ ok: true, value: { checkpoints: true } });
+ if (message.method === "pane.checkpointAvailability") availability.push(message.params?.available === true);
+ return Promise.resolve({ ok: true, value: null });
+ },
+ },
+ },
+ };
+ const root = createRoot(dom.window.document.getElementById("root")!);
+ const settle = async () => {
+ for (let pass = 0; pass < 20; pass += 1) await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
+ };
+ await act(async () => root.render());
+ for (let tries = 0; tries < 500 && availability.at(-1) !== true; tries += 1)
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
+ /// The client's next emit for the same chat, for `reason` (snapshot `connection`).
+ const emit = async (reason: string) => {
+ await act(async () =>
+ window_.cmuxAcpmuxBridge?.receive({ ...structuredClone(snapshot), connection: reason } as Snapshot),
+ );
+ await settle();
+ };
+ return { root, availability, emit };
+}
+
+// `connection` is why the client emitted, not whether the link is up (newtab/chatStore.ts): after
+// the attach's "connected" come "session changed" (#31983 sends it right after a local chat opens)
+// and every streamed event's kind. None of them takes the link down.
+for (const reason of ["session changed", "agent_message_chunk", "history"])
+ test(`checkpoints stay available when the chat's last emit was "${reason}"`, async () => {
+ const view = await pane();
+ try {
+ expect(view.availability.at(-1)).toBe(true);
+ await view.emit(reason);
+ expect(view.availability.at(-1)).toBe(true);
+ } finally {
+ await act(async () => view.root.unmount());
+ }
+ });
+
+test("checkpoints go offline when the link does", async () => {
+ const view = await pane();
+ try {
+ expect(view.availability.at(-1)).toBe(true);
+ await view.emit("disconnected");
+ expect(view.availability.at(-1)).toBe(false);
+ } finally {
+ await act(async () => view.root.unmount());
+ }
+});
+
+// Cloud chat recovery (App.tsx `cloudChatRecoveryActive`) and checkpoints read the same `linkUp`.
+test("the link is up for every emit reason but a lost or opening connection", async () => {
+ const { linkUp } = await import("../src/agent-session/acpmux/model");
+ for (const reason of ["connected", "session changed", "agent_message_chunk", "history", "failed"])
+ expect(linkUp(reason)).toBe(true);
+ for (const reason of ["disconnected", "connecting", "connecting: socket closed"]) expect(linkUp(reason)).toBe(false);
+});
+
+// A Cloud chat whose open failed on a socket that stayed open (direct.ts emits "failed", with no
+// rows and no `unavailable`: that is set only while no chat is selected) still reads its machine,
+// and a paused machine shows the Resume card. "failed" is not a lost link, but the chat was never
+// read, so cloud recovery treats it as one (#27031).
+test("a Cloud chat whose open failed on an open socket shows its paused machine's Resume card", async () => {
+ const { chat } = fixtures;
+ const base = chat([]);
+ const snapshot: Snapshot = {
+ ...base,
+ connection: "failed",
+ summary: { ...base.summary!, hostKind: "cloud", turnCount: 3 },
+ };
+ const account = ["user", null];
+ const reads: string[] = [];
+ window_.webkit = {
+ messageHandlers: {
+ agentSession: {
+ postMessage: (message: { method?: string }) => {
+ if (message.method === "ready") {
+ setTimeout(() => window_.cmuxAcpmuxBridge?.receive(structuredClone(snapshot)), 0);
+ return Promise.resolve({
+ ok: true,
+ value: {
+ protocolVersion: 1,
+ transport: "preview",
+ machineName: "Demo machine",
+ cloudMachineID: "vm-demo",
+ cloudAccount: account,
+ },
+ });
+ }
+ if (message.method === "cloudMachine.recoveryList") {
+ reads.push(message.method);
+ return Promise.resolve({
+ ok: true,
+ value: {
+ signedIn: true,
+ loaded: true,
+ complete: true,
+ account,
+ machines: [{ id: "vm-demo", name: "Demo machine", stage: "paused" }],
+ },
+ });
+ }
+ return Promise.resolve({ ok: true, value: null });
+ },
+ },
+ },
+ };
+ const root = createRoot(dom.window.document.getElementById("root")!);
+ const resume = () => dom.window.document.querySelector('[data-action="resumeMachine"]');
+ try {
+ await act(async () => root.render());
+ for (let tries = 0; tries < 500 && !resume(); tries += 1)
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
+ expect(reads.length).toBeGreaterThan(0);
+ expect(resume()).not.toBeNull();
+ } finally {
+ await act(async () => root.unmount());
+ }
+});