fix(frontend): keep a dialog opened from a dropdown menu open on phones (#15064)

### Why / What / How

**Why:** on a phone, a confirmation opened from a ⋮ menu closes itself
within a second. On prod that makes it impossible to fire an Expert from
the team page: the "Fire Vera?" sheet appears, then vanishes every time.
It affects every `Dialog` opened from a `DropdownMenu` item below the
`lg` breakpoint, including deleting a submission on the mobile creator
dashboard, removing a workflow from an Expert, and the library and
run-sidebar action menus.

**What:** a bottom sheet opened from a menu now stays open until the
user dismisses it. Tapping outside it, the ×, Escape and its own buttons
still close it.

**How:** below `lg` the `Dialog` molecule renders a vaul drawer, and
vaul does not move focus into the drawer when it opens. Focus stays in
the closing menu, which hands it back to its ⋮ trigger about 200 ms
later, outside the drawer. Radix's modal content vetoes that
focus-outside (`defaultPrevented` is true), but `DrawerWrap` passed
`onInteractOutside={handleClose}`, which closed the drawer regardless.
Removing that handler leaves dismissal to Radix, which already closes
the drawer on a real outside tap and on Escape through `onOpenChange`.
`AutopilotModeSelector` worked around the same bug by waiting for the
menu to close before opening its confirm; that workaround goes.

### Changes 🏗️

- `DrawerWrap`: drop `onInteractOutside={handleClose}`.
- `Drawer.test.tsx`: the drawer stays open when focus moves outside it,
and still closes on a pointer-down outside it.
- `AutopilotModeSelector`: open the Unsupervised confirm straight from
the menu selection.
- Not changed: `FileActionsMenu` keeps its own workaround
(`preventDefault` keeps the menu mounted while its dialog opens). It is
harmless, and no longer needed.

**Verified.** I reproduced the bug and checked the fix in Chromium,
using Playwright's iPhone 13 and Pixel 7 touch profiles on a throwaway
page that renders the real `ExpertTeamCard` with the backend responses
stubbed. Tapping ⋮ then "Fire Vera": before the fix the sheet survived 0
of 10 taps; after, it survived 10 of 10, and tapping Fire sent the
archive request. The same held for removing an Expert's workflow,
deleting a submission on the mobile creator dashboard, and the
Unsupervised confirm with its workaround removed (0/18 before, 18/18
after). Desktop worked before and after. Tapping outside, the ×, Keep
and Escape still close the sheet on all three profiles, whether it was
opened from the menu or from a plain button. The new focus-outside test
fails without the fix, and the outside-tap test fails when outside taps
are vetoed. The Dialog, DropdownMenu, team, chat input, creator
dashboard, artifacts and library suites pass (927 tests), and `tsc` is
clean. I did not drive the library agent, library card or run-sidebar
menus in a browser; they use the same menu and dialog. The iPhone
profile ran in Chromium, because WebKit does not launch on this machine.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Touch profiles (iPhone 13, Pixel 7): ⋮ → Fire keeps the sheet
open, preview loads, Fire sends the archive request
- [x] Same for workflow removal, mobile submission delete and the
Unsupervised confirm
- [x] Outside tap, ×, Keep and Escape still close the sheet (touch and
desktop)
  - [x] Frontend unit suites for the touched and affected areas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
Reinier van der LeerandClaude Opus 5.5 authored and GitHub committed 2026-09-30 20:04:49 +00:00
1 parent 263c2dccb6
commit d2a51bfd27
4 files changed
+28 -20

No files matched your search

@@ -25,7 +25,6 @@ export function AutopilotModeSelector({ sessionId, persistedMode }: Props) {
mode,
isDefault,
selectMode,
handleMenuClosed,
isConfirmOpen,
confirmUnsupervised,
cancelUnsupervised,
@@ -55,11 +54,7 @@ export function AutopilotModeSelector({ sessionId, persistedMode }: Props) {
{!isDefault && <span>{current.label}</span>}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-72"
onCloseAutoFocus={handleMenuClosed}
>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel className="text-xs font-medium text-zinc-500">
Approvals in this chat
</DropdownMenuLabel>
@@ -15,28 +15,18 @@ interface Args {
export function useAutopilotModeSelector({ sessionId, persistedMode }: Args) {
const choice = useAutopilotModeChoice(sessionId);
const choose = useAutopilotModeStore((state) => state.choose);
const [isConfirmPending, setIsConfirmPending] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const mode = choice ?? persistedMode ?? DEFAULT_AUTOPILOT_MODE;
function selectMode(value: string) {
if (!isAutopilotMode(value) || value === mode) return;
if (value === "unsupervised") {
setIsConfirmPending(true);
setIsConfirmOpen(true);
return;
}
choose(sessionId, value);
}
// Opened only once the menu has closed: on narrow screens the confirm is a
// drawer, and the click that picked the item otherwise dismisses it.
function handleMenuClosed(event: Event) {
if (!isConfirmPending) return;
event.preventDefault();
setIsConfirmPending(false);
setIsConfirmOpen(true);
}
function confirmUnsupervised() {
choose(sessionId, "unsupervised");
setIsConfirmOpen(false);
@@ -50,7 +40,6 @@ export function useAutopilotModeSelector({ sessionId, persistedMode }: Args) {
mode,
isDefault: mode === DEFAULT_AUTOPILOT_MODE,
selectMode,
handleMenuClosed,
isConfirmOpen,
confirmUnsupervised,
cancelUnsupervised,
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { Dialog } from "../Dialog";
@@ -64,6 +64,29 @@ describe("Dialog rendered as a drawer", () => {
expect(set).toHaveBeenCalledWith(false);
});
// A DropdownMenu that opened this drawer hands focus back to its trigger as
// it closes; Radix vetoes that focus-outside, and the drawer must honour it.
test("stays open when focus moves to an element outside it", () => {
const set = vi.fn();
render(<button type="button">Menu trigger</button>);
renderDrawer({ set });
act(() => screen.getByText("Menu trigger").focus());
expect(set).not.toHaveBeenCalled();
});
test("closes on a pointer-down outside it", async () => {
const set = vi.fn();
renderDrawer({ set });
// Radix attaches its outside-pointer listener one tick after mount.
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
fireEvent.pointerDown(document.body);
expect(set).toHaveBeenCalledWith(false);
});
test("associates an sr-only Description with constant text Dialog", () => {
renderDrawer({ set: vi.fn() });
@@ -58,7 +58,8 @@ export function DrawerWrap({
)}
data-testid={testId}
onEscapeKeyDown={handleEscapeKeyDown}
onInteractOutside={handleClose}
// No onInteractOutside close: Radix dismisses outside taps itself and
// vetoes the focus a closing DropdownMenu hands back to its trigger.
>
<div
className={cn(