From e4da3e2ffed2b83c91d8fda56bee12251d778e32 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 03:41:21 +0000 Subject: [PATCH 01/16] test(webviews): trace columns menu chip switch target Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/test/composer-menu-dismiss.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/webviews/test/composer-menu-dismiss.test.ts b/webviews/test/composer-menu-dismiss.test.ts index 9b5a9a27b91e..4184ad896571 100644 --- a/webviews/test/composer-menu-dismiss.test.ts +++ b/webviews/test/composer-menu-dismiss.test.ts @@ -248,7 +248,25 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { expect({ from, open: await settle(page, stage.chips, [from]) }).toEqual({ from, open: [from] }); // A menu that covers the other chip takes the press itself. if (await onTop(page, to)) { + if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") { + await page.evaluate(() => { + const events = ((window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents = []); + for (const type of ["pointerdown", "mousedown", "click"]) + document.addEventListener( + type, + (event) => + events.push({ + type, + target: (event.target as Element | null)?.closest("button")?.getAttribute("aria-label"), + defaultPrevented: event.defaultPrevented, + }), + true, + ); + }); + } await pressAt(page, chip(to)); + if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") + console.error("chip switch events", await page.evaluate(() => (window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents)); expect({ from, to, open: await settle(page, stage.chips, [to]) }).toEqual({ from, to, open: [to] }); } await page.keyboard.press("Escape"); From 8259d880d339863b1588f27d5f1c96f3cffabd2f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 03:41:21 +0000 Subject: [PATCH 02/16] test(webviews): trace columns menu chip switch target Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/test/composer-menu-dismiss.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/webviews/test/composer-menu-dismiss.test.ts b/webviews/test/composer-menu-dismiss.test.ts index 06a8b848e65b..d8594b526187 100644 --- a/webviews/test/composer-menu-dismiss.test.ts +++ b/webviews/test/composer-menu-dismiss.test.ts @@ -335,7 +335,25 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { await menusAtRest(page); // A menu that covers the other chip takes the press itself. if (await onTop(page, to)) { + if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") { + await page.evaluate(() => { + const events = ((window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents = []); + for (const type of ["pointerdown", "mousedown", "click"]) + document.addEventListener( + type, + (event) => + events.push({ + type, + target: (event.target as Element | null)?.closest("button")?.getAttribute("aria-label"), + defaultPrevented: event.defaultPrevented, + }), + true, + ); + }); + } await pressAt(page, chip(to)); + if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") + console.error("chip switch events", await page.evaluate(() => (window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents)); expect({ from, to, open: await settle(page, stage.chips, [to]) }).toEqual({ from, to, open: [to] }); } await page.keyboard.press("Escape"); From 1c28ed9cb79d9f16b91f38ccf8008483cdfa21ab Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:01:36 +0000 Subject: [PATCH 03/16] diagnose columns menu chip switch Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../src/agent-session/acpmux/QuickModelColumns.tsx | 3 ++- webviews/test/composer-menu-dismiss.test.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 03b3384dc5b3..966346a41689 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -977,7 +977,8 @@ function Columns({ // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { press.onPointerDown(event); - if (isMousePress(event)) event.preventDefault(); + // Diagnostic candidate: leave the compatibility mouse sequence intact so a sibling + // popover can receive its outside-dismiss click while this chip opens. }} // The closed chip opens on ArrowDown too, as the list menu's did. onKeyDown={(event) => { diff --git a/webviews/test/composer-menu-dismiss.test.ts b/webviews/test/composer-menu-dismiss.test.ts index d8594b526187..70df99fa1594 100644 --- a/webviews/test/composer-menu-dismiss.test.ts +++ b/webviews/test/composer-menu-dismiss.test.ts @@ -338,15 +338,19 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") { await page.evaluate(() => { const events = ((window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents = []); - for (const type of ["pointerdown", "mousedown", "click"]) + for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "click", "pointercancel"]) document.addEventListener( type, - (event) => + (event) => { + const button = (event.target as Element | null)?.closest("button"); events.push({ type, - target: (event.target as Element | null)?.closest("button")?.getAttribute("aria-label"), + target: button?.getAttribute("aria-label"), + connected: button?.isConnected, defaultPrevented: event.defaultPrevented, - }), + point: document.elementFromPoint((event as PointerEvent).clientX, (event as PointerEvent).clientY)?.closest("button")?.getAttribute("aria-label"), + }); + }, true, ); }); From bcd37fa234942ac4070df5b5fdeaea426e522a14 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:27:21 +0000 Subject: [PATCH 04/16] fix(webviews): close sibling popovers for columns chip Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../agent-session/acpmux/QuickModelColumns.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 966346a41689..69bbef4b0d81 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -10,7 +10,6 @@ import { AgentMark } from "../shared/AgentMark"; import { Popover, type UiVirtualAnchor } from "../../ui/Popover"; import { usePopoverTrigger } from "../../ui/popoverTrigger"; import { DrivenList, DrivenRow, InlineCard } from "../../ui/DrivenList"; -import { isMousePress } from "../../ui/pressRelease"; import { useOpenSide } from "../../ui/openSide"; import { AddAccountDialog } from "./AddAccountDialog"; import { SearchIcon, reasoningChoices, type Choice } from "./ComposerPickers"; @@ -171,6 +170,17 @@ function rowPackedToEnd(chip: HTMLElement): boolean { return false; } +/** Close a sibling composer popover before the columns trigger opens its own. */ +function closeSiblingComposerPopovers(button: HTMLButtonElement) { + const composer = button.closest(".acpmux-composer, .nt-composer"); + if (!composer) return; + for (const sibling of composer.querySelectorAll( + '[aria-haspopup][aria-expanded="true"]', + )) { + if (sibling !== button) sibling.click(); + } +} + function Columns({ open, onOpenChange, @@ -976,9 +986,9 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { + closeSiblingComposerPopovers(event.currentTarget); press.onPointerDown(event); - // Diagnostic candidate: leave the compatibility mouse sequence intact so a sibling - // popover can receive its outside-dismiss click while this chip opens. + if (event.pointerType === "mouse" && event.button === 0) event.preventDefault(); }} // The closed chip opens on ArrowDown too, as the list menu's did. onKeyDown={(event) => { From de7f45d63d9ff13c55fc92b76172877b7aefe1a9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:35:39 +0000 Subject: [PATCH 05/16] fix(webviews): find open sibling composer popovers Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/src/agent-session/acpmux/QuickModelColumns.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 69bbef4b0d81..d2a2df9edab3 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -172,13 +172,10 @@ function rowPackedToEnd(chip: HTMLElement): boolean { /** Close a sibling composer popover before the columns trigger opens its own. */ function closeSiblingComposerPopovers(button: HTMLButtonElement) { - const composer = button.closest(".acpmux-composer, .nt-composer"); - if (!composer) return; - for (const sibling of composer.querySelectorAll( + const siblings = [...button.ownerDocument.querySelectorAll( '[aria-haspopup][aria-expanded="true"]', - )) { - if (sibling !== button) sibling.click(); - } + )].filter((sibling) => sibling !== button && sibling.closest(".acpmux-composer, .nt-composer")); + for (const sibling of siblings) sibling.click(); } function Columns({ From 0c3a43cbfd9575dc52603dc9224f1e7f19769c40 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:45:05 +0000 Subject: [PATCH 06/16] fix(webviews): switch composer popovers on trigger press Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../acpmux/QuickModelColumns.tsx | 9 ------ webviews/src/ui/popoverTrigger.ts | 32 ++++++++++++++++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index d2a2df9edab3..069786991806 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -170,14 +170,6 @@ function rowPackedToEnd(chip: HTMLElement): boolean { return false; } -/** Close a sibling composer popover before the columns trigger opens its own. */ -function closeSiblingComposerPopovers(button: HTMLButtonElement) { - const siblings = [...button.ownerDocument.querySelectorAll( - '[aria-haspopup][aria-expanded="true"]', - )].filter((sibling) => sibling !== button && sibling.closest(".acpmux-composer, .nt-composer")); - for (const sibling of siblings) sibling.click(); -} - function Columns({ open, onOpenChange, @@ -983,7 +975,6 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { - closeSiblingComposerPopovers(event.currentTarget); press.onPointerDown(event); if (event.pointerType === "mouse" && event.button === 0) event.preventDefault(); }} diff --git a/webviews/src/ui/popoverTrigger.ts b/webviews/src/ui/popoverTrigger.ts index e04369c25ee3..7b66839a4899 100644 --- a/webviews/src/ui/popoverTrigger.ts +++ b/webviews/src/ui/popoverTrigger.ts @@ -6,7 +6,34 @@ import { useEffect, useRef, type PointerEvent } from "react"; import { isMousePress, trackPressRelease } from "./pressRelease"; +type OpenPopover = { element: HTMLElement; close(): void }; +const openPopovers = new Set(); + +/** Close other open popovers in this composer before a trigger starts its own press. */ +function closeOpenComposerPopovers(except: HTMLElement) { + const composer = except.closest(".acpmux-composer, .nt-composer"); + if (!composer) return; + for (const popover of [...openPopovers]) { + if (!popover.element.isConnected) { + openPopovers.delete(popover); + continue; + } + if (popover.element !== except && popover.element.closest(".acpmux-composer, .nt-composer") === composer) + popover.close(); + } +} + export function usePopoverTrigger(open: boolean, setOpen: (open: boolean) => void, show?: () => void) { + const targetRef = useRef(null); + const setOpenRef = useRef(setOpen); + setOpenRef.current = setOpen; + useEffect(() => { + const element = targetRef.current; + if (!open || !element) return; + const popover = { element, close: () => setOpenRef.current(false) }; + openPopovers.add(popover); + return () => openPopovers.delete(popover); + }, [open]); const openAtPress = useRef(undefined); // A mouse press already opened or closed it: the click it ends with does nothing. const pressed = useRef(false); @@ -22,6 +49,8 @@ export function usePopoverTrigger(open: boolean, setOpen: (open: boolean) => voi ); return { onPointerDown: (event: PointerEvent) => { + targetRef.current = event.currentTarget; + closeOpenComposerPopovers(event.currentTarget); openAtPress.current = open; if (!isMousePress(event)) return; // Opening a menu moves focus into it, and the browser's transient :active state goes with it @@ -63,7 +92,8 @@ export function usePopoverTrigger(open: boolean, setOpen: (open: boolean) => voi onMouseDown: (event: { preventDefault(): void }) => { if (open) event.preventDefault(); }, - onClick: () => { + onClick: (event: { currentTarget: HTMLElement }) => { + targetRef.current = event.currentTarget; const wasOpen = openAtPress.current ?? open; openAtPress.current = undefined; if (pressed.current) { From 6774e5515197ef67524acc179f6d7b60ad0cacaf Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:51:20 +0000 Subject: [PATCH 07/16] Revert "fix(webviews): switch composer popovers on trigger press" This reverts commit 0c3a43cbfd9575dc52603dc9224f1e7f19769c40. Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../acpmux/QuickModelColumns.tsx | 9 ++++++ webviews/src/ui/popoverTrigger.ts | 32 +------------------ 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 069786991806..d2a2df9edab3 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -170,6 +170,14 @@ function rowPackedToEnd(chip: HTMLElement): boolean { return false; } +/** Close a sibling composer popover before the columns trigger opens its own. */ +function closeSiblingComposerPopovers(button: HTMLButtonElement) { + const siblings = [...button.ownerDocument.querySelectorAll( + '[aria-haspopup][aria-expanded="true"]', + )].filter((sibling) => sibling !== button && sibling.closest(".acpmux-composer, .nt-composer")); + for (const sibling of siblings) sibling.click(); +} + function Columns({ open, onOpenChange, @@ -975,6 +983,7 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { + closeSiblingComposerPopovers(event.currentTarget); press.onPointerDown(event); if (event.pointerType === "mouse" && event.button === 0) event.preventDefault(); }} diff --git a/webviews/src/ui/popoverTrigger.ts b/webviews/src/ui/popoverTrigger.ts index 7b66839a4899..e04369c25ee3 100644 --- a/webviews/src/ui/popoverTrigger.ts +++ b/webviews/src/ui/popoverTrigger.ts @@ -6,34 +6,7 @@ import { useEffect, useRef, type PointerEvent } from "react"; import { isMousePress, trackPressRelease } from "./pressRelease"; -type OpenPopover = { element: HTMLElement; close(): void }; -const openPopovers = new Set(); - -/** Close other open popovers in this composer before a trigger starts its own press. */ -function closeOpenComposerPopovers(except: HTMLElement) { - const composer = except.closest(".acpmux-composer, .nt-composer"); - if (!composer) return; - for (const popover of [...openPopovers]) { - if (!popover.element.isConnected) { - openPopovers.delete(popover); - continue; - } - if (popover.element !== except && popover.element.closest(".acpmux-composer, .nt-composer") === composer) - popover.close(); - } -} - export function usePopoverTrigger(open: boolean, setOpen: (open: boolean) => void, show?: () => void) { - const targetRef = useRef(null); - const setOpenRef = useRef(setOpen); - setOpenRef.current = setOpen; - useEffect(() => { - const element = targetRef.current; - if (!open || !element) return; - const popover = { element, close: () => setOpenRef.current(false) }; - openPopovers.add(popover); - return () => openPopovers.delete(popover); - }, [open]); const openAtPress = useRef(undefined); // A mouse press already opened or closed it: the click it ends with does nothing. const pressed = useRef(false); @@ -49,8 +22,6 @@ export function usePopoverTrigger(open: boolean, setOpen: (open: boolean) => voi ); return { onPointerDown: (event: PointerEvent) => { - targetRef.current = event.currentTarget; - closeOpenComposerPopovers(event.currentTarget); openAtPress.current = open; if (!isMousePress(event)) return; // Opening a menu moves focus into it, and the browser's transient :active state goes with it @@ -92,8 +63,7 @@ export function usePopoverTrigger(open: boolean, setOpen: (open: boolean) => voi onMouseDown: (event: { preventDefault(): void }) => { if (open) event.preventDefault(); }, - onClick: (event: { currentTarget: HTMLElement }) => { - targetRef.current = event.currentTarget; + onClick: () => { const wasOpen = openAtPress.current ?? open; openAtPress.current = undefined; if (pressed.current) { From 47fa21f216508fa22ca9c32b7b2baae47d807cdf Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:52:52 +0000 Subject: [PATCH 08/16] fix(webviews): close folder on columns trigger press Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/src/agent-session/acpmux/FolderPopover.tsx | 11 +++++++++++ .../src/agent-session/acpmux/QuickModelColumns.tsx | 12 +++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/webviews/src/agent-session/acpmux/FolderPopover.tsx b/webviews/src/agent-session/acpmux/FolderPopover.tsx index a2284071a1e4..48a1892c165f 100644 --- a/webviews/src/agent-session/acpmux/FolderPopover.tsx +++ b/webviews/src/agent-session/acpmux/FolderPopover.tsx @@ -171,6 +171,17 @@ export function FolderPopover({ if (escaped.current) trigger.current?.focus(); escaped.current = false; }; + useEffect(() => { + if (!open) return; + const switchPopover = (event: Event) => { + const target = (event as CustomEvent).detail; + if (!(target instanceof Element)) return; + if (target.closest(".acpmux-composer, .nt-composer") === trigger.current?.closest(".acpmux-composer, .nt-composer")) + close(); + }; + document.addEventListener("cmux-composer-popover-switch", switchPopover); + return () => document.removeEventListener("cmux-composer-popover-switch", switchPopover); + }, [open, close]); const submit = (value: string) => { // Add folder… keeps the menu open: its list now adds a folder. if (value === ACTION_EXTRA) { diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index d2a2df9edab3..aeccbdaa506b 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -170,14 +170,6 @@ function rowPackedToEnd(chip: HTMLElement): boolean { return false; } -/** Close a sibling composer popover before the columns trigger opens its own. */ -function closeSiblingComposerPopovers(button: HTMLButtonElement) { - const siblings = [...button.ownerDocument.querySelectorAll( - '[aria-haspopup][aria-expanded="true"]', - )].filter((sibling) => sibling !== button && sibling.closest(".acpmux-composer, .nt-composer")); - for (const sibling of siblings) sibling.click(); -} - function Columns({ open, onOpenChange, @@ -983,7 +975,9 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { - closeSiblingComposerPopovers(event.currentTarget); + event.currentTarget.ownerDocument.dispatchEvent( + new CustomEvent("cmux-composer-popover-switch", { detail: event.currentTarget }), + ); press.onPointerDown(event); if (event.pointerType === "mouse" && event.button === 0) event.preventDefault(); }} From 9b2192c00f61a67f0c3c552650b6b8e01807914a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:55:25 +0000 Subject: [PATCH 09/16] test(webviews): remove temporary pointer trace Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/test/composer-menu-dismiss.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/webviews/test/composer-menu-dismiss.test.ts b/webviews/test/composer-menu-dismiss.test.ts index 70df99fa1594..d8594b526187 100644 --- a/webviews/test/composer-menu-dismiss.test.ts +++ b/webviews/test/composer-menu-dismiss.test.ts @@ -338,19 +338,15 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") { await page.evaluate(() => { const events = ((window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents = []); - for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "click", "pointercancel"]) + for (const type of ["pointerdown", "mousedown", "click"]) document.addEventListener( type, - (event) => { - const button = (event.target as Element | null)?.closest("button"); + (event) => events.push({ type, - target: button?.getAttribute("aria-label"), - connected: button?.isConnected, + target: (event.target as Element | null)?.closest("button")?.getAttribute("aria-label"), defaultPrevented: event.defaultPrevented, - point: document.elementFromPoint((event as PointerEvent).clientX, (event as PointerEvent).clientY)?.closest("button")?.getAttribute("aria-label"), - }); - }, + }), true, ); }); From b205f0042454bb872fca58337292ad746a75888f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 05:57:14 +0000 Subject: [PATCH 10/16] style(webviews): wrap folder popover switch guard Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/src/agent-session/acpmux/FolderPopover.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/FolderPopover.tsx b/webviews/src/agent-session/acpmux/FolderPopover.tsx index 48a1892c165f..2dfacf6e0114 100644 --- a/webviews/src/agent-session/acpmux/FolderPopover.tsx +++ b/webviews/src/agent-session/acpmux/FolderPopover.tsx @@ -176,7 +176,10 @@ export function FolderPopover({ const switchPopover = (event: Event) => { const target = (event as CustomEvent).detail; if (!(target instanceof Element)) return; - if (target.closest(".acpmux-composer, .nt-composer") === trigger.current?.closest(".acpmux-composer, .nt-composer")) + if ( + target.closest(".acpmux-composer, .nt-composer") === + trigger.current?.closest(".acpmux-composer, .nt-composer") + ) close(); }; document.addEventListener("cmux-composer-popover-switch", switchPopover); From c88011e6d1a610b8fe29c42a763713e9fff108f2 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 06:00:50 +0000 Subject: [PATCH 11/16] style(webviews): retain shared mouse press check Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/src/agent-session/acpmux/QuickModelColumns.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index aeccbdaa506b..25940da70e15 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -10,6 +10,7 @@ import { AgentMark } from "../shared/AgentMark"; import { Popover, type UiVirtualAnchor } from "../../ui/Popover"; import { usePopoverTrigger } from "../../ui/popoverTrigger"; import { DrivenList, DrivenRow, InlineCard } from "../../ui/DrivenList"; +import { isMousePress } from "../../ui/pressRelease"; import { useOpenSide } from "../../ui/openSide"; import { AddAccountDialog } from "./AddAccountDialog"; import { SearchIcon, reasoningChoices, type Choice } from "./ComposerPickers"; @@ -979,7 +980,7 @@ function Columns({ new CustomEvent("cmux-composer-popover-switch", { detail: event.currentTarget }), ); press.onPointerDown(event); - if (event.pointerType === "mouse" && event.button === 0) event.preventDefault(); + if (isMousePress(event)) event.preventDefault(); }} // The closed chip opens on ArrowDown too, as the list menu's did. onKeyDown={(event) => { From 367076998b5ff8496c4babeaea3d775220b6c987 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 06:09:02 +0000 Subject: [PATCH 12/16] fix(webviews): remove diagnostic switch trace Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../src/agent-session/acpmux/FolderPopover.tsx | 7 +++---- webviews/test/composer-menu-dismiss.test.ts | 18 ------------------ 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/webviews/src/agent-session/acpmux/FolderPopover.tsx b/webviews/src/agent-session/acpmux/FolderPopover.tsx index 2dfacf6e0114..6c9a557e1368 100644 --- a/webviews/src/agent-session/acpmux/FolderPopover.tsx +++ b/webviews/src/agent-session/acpmux/FolderPopover.tsx @@ -176,10 +176,9 @@ export function FolderPopover({ const switchPopover = (event: Event) => { const target = (event as CustomEvent).detail; if (!(target instanceof Element)) return; - if ( - target.closest(".acpmux-composer, .nt-composer") === - trigger.current?.closest(".acpmux-composer, .nt-composer") - ) + const targetComposer = target.closest(".acpmux-composer, .nt-composer"); + const folderComposer = trigger.current?.closest(".acpmux-composer, .nt-composer"); + if (targetComposer && targetComposer === folderComposer) close(); }; document.addEventListener("cmux-composer-popover-switch", switchPopover); diff --git a/webviews/test/composer-menu-dismiss.test.ts b/webviews/test/composer-menu-dismiss.test.ts index d8594b526187..06a8b848e65b 100644 --- a/webviews/test/composer-menu-dismiss.test.ts +++ b/webviews/test/composer-menu-dismiss.test.ts @@ -335,25 +335,7 @@ await requireBrowserLane("composer-menu-dismiss.test.ts", async () => { await menusAtRest(page); // A menu that covers the other chip takes the press itself. if (await onTop(page, to)) { - if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") { - await page.evaluate(() => { - const events = ((window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents = []); - for (const type of ["pointerdown", "mousedown", "click"]) - document.addEventListener( - type, - (event) => - events.push({ - type, - target: (event.target as Element | null)?.closest("button")?.getAttribute("aria-label"), - defaultPrevented: event.defaultPrevented, - }), - true, - ); - }); - } await pressAt(page, chip(to)); - if (stage.variant === "new-chat-reasoning" && width === 760 && from === "Folder" && to === "Model") - console.error("chip switch events", await page.evaluate(() => (window as unknown as { chipSwitchEvents?: unknown[] }).chipSwitchEvents)); expect({ from, to, open: await settle(page, stage.chips, [to]) }).toEqual({ from, to, open: [to] }); } await page.keyboard.press("Escape"); From b85cc637375767d3430213a0c631f6191d1dd3ff Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 06:14:28 +0000 Subject: [PATCH 13/16] docs(webviews): explain composer popover switch Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/src/agent-session/acpmux/QuickModelColumns.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 25940da70e15..9c6d5f494ae1 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -976,6 +976,8 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { + // A sibling composer popover can consume the compatibility click while it dismisses; + // close its React state while this trigger is still the stable pointer target. event.currentTarget.ownerDocument.dispatchEvent( new CustomEvent("cmux-composer-popover-switch", { detail: event.currentTarget }), ); From 200851cbaf781b28bb731f14d3a91f1cc32456cc Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 06:56:39 +0000 Subject: [PATCH 14/16] fix(webviews): close both folder picker variants on model press Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../agent-session/acpmux/ComposerContext.tsx | 3 ++- .../agent-session/acpmux/FolderPopover.tsx | 16 ++----------- .../acpmux/QuickModelColumns.tsx | 4 ++-- .../agent-session/acpmux/locationPopover.ts | 24 ++++++++++++++++++- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/webviews/src/agent-session/acpmux/ComposerContext.tsx b/webviews/src/agent-session/acpmux/ComposerContext.tsx index cbf1ceb722cf..d2a06eded4d3 100644 --- a/webviews/src/agent-session/acpmux/ComposerContext.tsx +++ b/webviews/src/agent-session/acpmux/ComposerContext.tsx @@ -56,7 +56,7 @@ import { projectSuffixes } from "./projectSuffix"; import { belowCheckout, folderRepo, isMainCheckout, worktreeName, type ProjectRepo } from "./projectRepos"; import { isAgentHome, projectLabel } from "./sessionList"; import { translate as t, useT } from "./i18n"; -import { useLocationOpener, useLocationPopover } from "./locationPopover"; +import { useComposerPopoverSwitch, useLocationOpener, useLocationPopover } from "./locationPopover"; import { usePopoverTrigger } from "../../ui/popoverTrigger"; import { focusAfterEscape } from "../../ui/escapeFocus"; import type { RunModeRows } from "./sandboxRun"; @@ -1262,6 +1262,7 @@ function LocationPicker({ }; const press = usePopoverTrigger(open, changeOpen); const trigger = useRef(null); + useComposerPopoverSwitch(open, trigger, () => changeOpen(false)); useLocationOpener(disabled ? undefined : menu, () => setOpen(true)); useEffect(() => { if (!closeOnTrustAsk || !open) return; diff --git a/webviews/src/agent-session/acpmux/FolderPopover.tsx b/webviews/src/agent-session/acpmux/FolderPopover.tsx index 6c9a557e1368..0d27aa172861 100644 --- a/webviews/src/agent-session/acpmux/FolderPopover.tsx +++ b/webviews/src/agent-session/acpmux/FolderPopover.tsx @@ -6,7 +6,7 @@ import { AddProjectDialog, type BrowseRequest, type BrowseResult } from "./AddPr import { ExtraFolderList } from "./ExtraFolderList"; import type { ExtraFolderRows } from "./extraFolders"; import { translate as t } from "./i18n"; -import { useLocationOpener, useLocationPopover } from "./locationPopover"; +import { useComposerPopoverSwitch, useLocationOpener, useLocationPopover } from "./locationPopover"; import { ProjectBadge } from "./ProjectBadge"; import { projectSuffixes } from "./projectSuffix"; import { folderRepo, groupByRepo, isMainCheckout, shownRepoRows, worktreeName, type ProjectRepo } from "./projectRepos"; @@ -171,19 +171,7 @@ export function FolderPopover({ if (escaped.current) trigger.current?.focus(); escaped.current = false; }; - useEffect(() => { - if (!open) return; - const switchPopover = (event: Event) => { - const target = (event as CustomEvent).detail; - if (!(target instanceof Element)) return; - const targetComposer = target.closest(".acpmux-composer, .nt-composer"); - const folderComposer = trigger.current?.closest(".acpmux-composer, .nt-composer"); - if (targetComposer && targetComposer === folderComposer) - close(); - }; - document.addEventListener("cmux-composer-popover-switch", switchPopover); - return () => document.removeEventListener("cmux-composer-popover-switch", switchPopover); - }, [open, close]); + useComposerPopoverSwitch(open, trigger, close); const submit = (value: string) => { // Add folder… keeps the menu open: its list now adds a folder. if (value === ACTION_EXTRA) { diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 9c6d5f494ae1..9015682fe33c 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -976,8 +976,8 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { - // A sibling composer popover can consume the compatibility click while it dismisses; - // close its React state while this trigger is still the stable pointer target. + // The compatibility click can be lost after opening the columns on pointerdown. + // Close the sibling location menu before opening and focusing this one. event.currentTarget.ownerDocument.dispatchEvent( new CustomEvent("cmux-composer-popover-switch", { detail: event.currentTarget }), ); diff --git a/webviews/src/agent-session/acpmux/locationPopover.ts b/webviews/src/agent-session/acpmux/locationPopover.ts index e3848fb1c2dd..5f1e1ecb48c1 100644 --- a/webviews/src/agent-session/acpmux/locationPopover.ts +++ b/webviews/src/agent-session/acpmux/locationPopover.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import type { UiVirtualAnchor } from "../../ui/Popover"; import { useOpenSide, type UiPopupFlip, type UiPopupSide } from "../../ui/openSide"; import { registerPicker } from "./pickerOpeners"; @@ -6,6 +6,28 @@ import { registerPicker } from "./pickerOpeners"; /// The composer a location control heads: the chat pane's form, or the New Tab page's composer. const COMPOSER = "form.acpmux-composer, .nt-composer"; +/** Close a location menu before the columns model chip opens on the same pointer press. */ +export function useComposerPopoverSwitch( + open: boolean, + trigger: RefObject, + close: () => void, +) { + const latestClose = useRef(close); + latestClose.current = close; + useEffect(() => { + const doc = trigger.current?.ownerDocument; + if (!open || !doc) return; + const switchPopover = (event: Event) => { + const target = (event as CustomEvent).detail; + if (!(target instanceof Element)) return; + const composer = trigger.current?.closest(COMPOSER); + if (composer && target.closest(COMPOSER) === composer) latestClose.current(); + }; + doc.addEventListener("cmux-composer-popover-switch", switchPopover); + return () => doc.removeEventListener("cmux-composer-popover-switch", switchPopover); + }, [open, trigger]); +} + /// The side away from the conversation (Leo 2026-10-29: "make them open away from teh chat, which /// is obviously up if it's up, down if it's down"). It is read from where the composer sits in the /// window when the menu opens, never from which page it is: a composer in the lower half has the From 5a4bcd36659624938209a3c65d5e257c3f020ca6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 07:20:36 +0000 Subject: [PATCH 15/16] style(webviews): format composer popover switch hook Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- webviews/src/agent-session/acpmux/locationPopover.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/locationPopover.ts b/webviews/src/agent-session/acpmux/locationPopover.ts index 5f1e1ecb48c1..b67121e14172 100644 --- a/webviews/src/agent-session/acpmux/locationPopover.ts +++ b/webviews/src/agent-session/acpmux/locationPopover.ts @@ -7,11 +7,7 @@ import { registerPicker } from "./pickerOpeners"; const COMPOSER = "form.acpmux-composer, .nt-composer"; /** Close a location menu before the columns model chip opens on the same pointer press. */ -export function useComposerPopoverSwitch( - open: boolean, - trigger: RefObject, - close: () => void, -) { +export function useComposerPopoverSwitch(open: boolean, trigger: RefObject, close: () => void) { const latestClose = useRef(close); latestClose.current = close; useEffect(() => { From 4ac197e6887382445560055e9d4eee1ed32cae1d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 4 Nov 2026 07:40:04 +0000 Subject: [PATCH 16/16] fix(webviews): keep model columns trigger mounted before opening Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-c05f94d269ac08017254 --- .../agent-session/acpmux/ComposerContext.tsx | 3 +-- .../agent-session/acpmux/FolderPopover.tsx | 3 +-- .../src/agent-session/acpmux/ModelPicker.tsx | 6 +++++- .../acpmux/QuickModelColumns.tsx | 5 ----- .../agent-session/acpmux/locationPopover.ts | 20 +------------------ 5 files changed, 8 insertions(+), 29 deletions(-) diff --git a/webviews/src/agent-session/acpmux/ComposerContext.tsx b/webviews/src/agent-session/acpmux/ComposerContext.tsx index d2a06eded4d3..cbf1ceb722cf 100644 --- a/webviews/src/agent-session/acpmux/ComposerContext.tsx +++ b/webviews/src/agent-session/acpmux/ComposerContext.tsx @@ -56,7 +56,7 @@ import { projectSuffixes } from "./projectSuffix"; import { belowCheckout, folderRepo, isMainCheckout, worktreeName, type ProjectRepo } from "./projectRepos"; import { isAgentHome, projectLabel } from "./sessionList"; import { translate as t, useT } from "./i18n"; -import { useComposerPopoverSwitch, useLocationOpener, useLocationPopover } from "./locationPopover"; +import { useLocationOpener, useLocationPopover } from "./locationPopover"; import { usePopoverTrigger } from "../../ui/popoverTrigger"; import { focusAfterEscape } from "../../ui/escapeFocus"; import type { RunModeRows } from "./sandboxRun"; @@ -1262,7 +1262,6 @@ function LocationPicker({ }; const press = usePopoverTrigger(open, changeOpen); const trigger = useRef(null); - useComposerPopoverSwitch(open, trigger, () => changeOpen(false)); useLocationOpener(disabled ? undefined : menu, () => setOpen(true)); useEffect(() => { if (!closeOnTrustAsk || !open) return; diff --git a/webviews/src/agent-session/acpmux/FolderPopover.tsx b/webviews/src/agent-session/acpmux/FolderPopover.tsx index 0d27aa172861..a2284071a1e4 100644 --- a/webviews/src/agent-session/acpmux/FolderPopover.tsx +++ b/webviews/src/agent-session/acpmux/FolderPopover.tsx @@ -6,7 +6,7 @@ import { AddProjectDialog, type BrowseRequest, type BrowseResult } from "./AddPr import { ExtraFolderList } from "./ExtraFolderList"; import type { ExtraFolderRows } from "./extraFolders"; import { translate as t } from "./i18n"; -import { useComposerPopoverSwitch, useLocationOpener, useLocationPopover } from "./locationPopover"; +import { useLocationOpener, useLocationPopover } from "./locationPopover"; import { ProjectBadge } from "./ProjectBadge"; import { projectSuffixes } from "./projectSuffix"; import { folderRepo, groupByRepo, isMainCheckout, shownRepoRows, worktreeName, type ProjectRepo } from "./projectRepos"; @@ -171,7 +171,6 @@ export function FolderPopover({ if (escaped.current) trigger.current?.focus(); escaped.current = false; }; - useComposerPopoverSwitch(open, trigger, close); const submit = (value: string) => { // Add folder… keeps the menu open: its list now adds a folder. if (value === ACTION_EXTRA) { diff --git a/webviews/src/agent-session/acpmux/ModelPicker.tsx b/webviews/src/agent-session/acpmux/ModelPicker.tsx index 022b6d30c82f..c73451a3c884 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.tsx @@ -931,7 +931,11 @@ export function ModelPicker(props: ModelPickerProps) { // opens the full picker, which has no rail either. // A folder profile is a machine choice, so its current models stay in the plain compact list // while the harness-only rail remains absent. - if (!modelsNeeded || !quick || (harnessFixed && !fork) || current?.folder) return undefined; + if (!quick || (harnessFixed && !fork) || current?.folder) return undefined; + // Keep the columns trigger mounted before its first press. Replacing the list trigger as it + // opens loses that press's click, so another popover never receives its outside dismissal. + // The rows stay lazy until the picker opens. + if (!modelsNeeded) return []; const row = (entry: HarnessChoice, model: ModelChoice) => quickRow(entry.id, model); // A started chat that forks lists every harness, as a New Tab does; its own stays the chat's. const startable = (fork ? allHarnesses : harnesses).filter( diff --git a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx index 9015682fe33c..03b3384dc5b3 100644 --- a/webviews/src/agent-session/acpmux/QuickModelColumns.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelColumns.tsx @@ -976,11 +976,6 @@ function Columns({ // A mouse press never focuses the chip, as the list menu's button: a press that closes the // menu and lands a pick leaves the keyboard in the prompt the picker focused. onPointerDown={(event) => { - // The compatibility click can be lost after opening the columns on pointerdown. - // Close the sibling location menu before opening and focusing this one. - event.currentTarget.ownerDocument.dispatchEvent( - new CustomEvent("cmux-composer-popover-switch", { detail: event.currentTarget }), - ); press.onPointerDown(event); if (isMousePress(event)) event.preventDefault(); }} diff --git a/webviews/src/agent-session/acpmux/locationPopover.ts b/webviews/src/agent-session/acpmux/locationPopover.ts index b67121e14172..e3848fb1c2dd 100644 --- a/webviews/src/agent-session/acpmux/locationPopover.ts +++ b/webviews/src/agent-session/acpmux/locationPopover.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UiVirtualAnchor } from "../../ui/Popover"; import { useOpenSide, type UiPopupFlip, type UiPopupSide } from "../../ui/openSide"; import { registerPicker } from "./pickerOpeners"; @@ -6,24 +6,6 @@ import { registerPicker } from "./pickerOpeners"; /// The composer a location control heads: the chat pane's form, or the New Tab page's composer. const COMPOSER = "form.acpmux-composer, .nt-composer"; -/** Close a location menu before the columns model chip opens on the same pointer press. */ -export function useComposerPopoverSwitch(open: boolean, trigger: RefObject, close: () => void) { - const latestClose = useRef(close); - latestClose.current = close; - useEffect(() => { - const doc = trigger.current?.ownerDocument; - if (!open || !doc) return; - const switchPopover = (event: Event) => { - const target = (event as CustomEvent).detail; - if (!(target instanceof Element)) return; - const composer = trigger.current?.closest(COMPOSER); - if (composer && target.closest(COMPOSER) === composer) latestClose.current(); - }; - doc.addEventListener("cmux-composer-popover-switch", switchPopover); - return () => doc.removeEventListener("cmux-composer-popover-switch", switchPopover); - }, [open, trigger]); -} - /// The side away from the conversation (Leo 2026-10-29: "make them open away from teh chat, which /// is obviously up if it's up, down if it's down"). It is read from where the composer sits in the /// window when the menu opens, never from which page it is: a composer in the lower half has the