From 866b4e14aaea9ddb8728a8195003d6397289ac2f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 7 Oct 2026 11:49:38 +0200 Subject: [PATCH 1/2] fix(chat): close the reasoning menu and model quick-pick on Escape (#1039) The reasoning effort menu moves onto the shared Popover, which gives it Escape, outside click and focus return. The model quick-pick gets a document Escape handler that refocuses its pill. Escape stops there, so no window-level shortcut hears the same press. --- .../ai-edition/LeftPanel.tooltips.test.tsx | 56 ++++++++++++ src/components/ai-edition/LeftPanel.tsx | 87 ++++++++++--------- 2 files changed, 101 insertions(+), 42 deletions(-) diff --git a/src/components/ai-edition/LeftPanel.tooltips.test.tsx b/src/components/ai-edition/LeftPanel.tooltips.test.tsx index edd0ccdf2..29ecc9674 100644 --- a/src/components/ai-edition/LeftPanel.tooltips.test.tsx +++ b/src/components/ai-edition/LeftPanel.tooltips.test.tsx @@ -220,3 +220,59 @@ describe("ChatStripPanel tooltips", () => { expect(chatCompact).not.toHaveBeenCalled(); }); }); + +// Issue #1039: the reasoning menu closed neither on Escape nor on a click outside, and the model +// quick-pick not on Escape. Escape gives the focus back to the button, and goes no further. +describe("ChatStripPanel composer menus", () => { + beforeEach(() => { + snapshot.value = { + config: { provider: "openai", model: "gpt-x" }, + connectedProviders: ["openai"], + availableProviders: [], + credentialSummary: [], + }; + }); + + /** Presses Escape on `target` and says whether a window-level shortcut handler heard it. */ + function pressEscape(target: Element) { + const shortcut = vi.fn(); + window.addEventListener("keydown", shortcut); + fireEvent.keyDown(target, { key: "Escape" }); + window.removeEventListener("keydown", shortcut); + return shortcut.mock.calls.length > 0; + } + + it("closes the reasoning menu on Escape, back on its button", async () => { + renderPanel(); + const chip = await screen.findByRole("button", { name: "chat.reasoningEffortLabel" }); + fireEvent.click(chip); + const menu = await screen.findByRole("menu"); + expect(chip).toHaveAttribute("aria-expanded", "true"); + + expect(pressEscape(menu)).toBe(false); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + await waitFor(() => expect(chip).toHaveFocus()); + }); + + it("closes the reasoning menu on a click outside", async () => { + renderPanel(); + fireEvent.click(await screen.findByRole("button", { name: "chat.reasoningEffortLabel" })); + await screen.findByRole("menu"); + // The outside-click listener is armed a tick after the menu opens. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + fireEvent.pointerDown(document.body); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + }); + + it("closes the model quick-pick on Escape, back on its pill", async () => { + renderPanel(); + const pill = await screen.findByRole("button", { name: "chat.modelLabel" }); + await waitFor(() => expect(pill).toHaveTextContent("gpt-x")); + fireEvent.click(pill); + const search = await screen.findByPlaceholderText("chat.searchModels"); + + expect(pressEscape(search)).toBe(false); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(pill).toHaveFocus(); + }); +}); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 82e809268..e1852e554 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, Check, Loader2, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip } from "@/components/ui/tooltip"; import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; import { useScopedT } from "@/contexts/I18nContext"; @@ -462,11 +463,6 @@ export function ChatStripPanel() { // default-collapsed preview lightweight and the click-to-expand obvious. const [thinkingExpandedIds, setThinkingExpandedIds] = useState>(() => new Set()); const [reasoningOpen, setReasoningOpen] = useState(false); - const reasoningButtonRef = useRef(null); - const [reasoningMenuRect, setReasoningMenuRect] = useState<{ - left: number; - bottom: number; - } | null>(null); const [reasoningBusy, setReasoningBusy] = useState(false); // null until the first llmGetSnapshot() lands: "unknown", not "none". const [connectedProviders, setConnectedProviders] = useState(null); @@ -828,18 +824,6 @@ export function ChatStripPanel() { [llmConfig, t], ); - const toggleReasoningOpen = useCallback(() => { - setReasoningOpen((wasOpen) => { - if (!wasOpen) { - const rect = reasoningButtonRef.current?.getBoundingClientRect(); - if (rect) { - setReasoningMenuRect({ left: rect.left, bottom: window.innerHeight - rect.top + 4 }); - } - } - return !wasOpen; - }); - }, []); - const toggleModelPopoverOpen = useCallback(() => { // Mirrors axcut's providerButtonRef handler: with no provider configured // yet there's nothing to quick-pick a model from, so go straight to the @@ -868,6 +852,22 @@ export function ChatStripPanel() { }); }, [llmConfig, openProviderSettings]); + // Escape closes the model quick-pick and hands focus back to its pill (#1039). On the document, + // as the dialogs do (Modals.tsx), so it is heard from the search field too, and it goes no + // further, so nothing else bound to it acts on the same press. + useEffect(() => { + if (!modelPopoverOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + setModelPopoverOpen(false); + modelButtonRef.current?.focus(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [modelPopoverOpen]); + // Prefer the main process's estimate of the windowed history it actually sends, so // manual compaction can shrink this meter while the complete transcript remains // visible. The renderer estimate is only shown until the first answer arrives -- @@ -1487,36 +1487,39 @@ export function ChatStripPanel() { {reasoningLabel ? ( - - ) : null} - {reasoningOpen && reasoningMenuRect - ? createPortal( + // The shared popover, for its Escape and outside click (#1039); Escape goes no + // further, so nothing else bound to it acts on the same press. + + + + + event.stopPropagation()} + >
{getReasoningEffortOptions(llmConfig?.provider ?? "").map((option) => ( @@ -1546,10 +1549,10 @@ export function ChatStripPanel() { {option === currentReasoningEffort ? : null} ))} -
, - document.body, - ) - : null} + +
+
+ ) : null} {modelPopoverOpen && modelPopoverRect && llmConfig ? ( Date: Wed, 7 Oct 2026 12:05:43 +0200 Subject: [PATCH 2/2] fix: leave Escape to the rewind confirmation over the model quick-pick --- src/components/ai-edition/LeftPanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index e1852e554..3a490a772 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -861,12 +861,14 @@ export function ChatStripPanel() { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); + // The rewind confirmation, open above it, takes this press: one Escape, one layer. + if (rewindFor) return; setModelPopoverOpen(false); modelButtonRef.current?.focus(); }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [modelPopoverOpen]); + }, [modelPopoverOpen, rewindFor]); // Prefer the main process's estimate of the windowed history it actually sends, so // manual compaction can shrink this meter while the complete transcript remains