mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-13 17:51:37 -10:00
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:
1 parent
263c2dccb6
commit
d2a51bfd27
4 files changed
+28
-20
No files matched your search
+1
-6
@@ -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>
|
||||
|
||||
+1
-12
@@ -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,
|
||||
|
||||
+24
-1
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user