Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/components/ai-edition/LeftPanel.tooltips.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
89 changes: 47 additions & 42 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -462,11 +463,6 @@ export function ChatStripPanel() {
// default-collapsed preview lightweight and the click-to-expand obvious.
const [thinkingExpandedIds, setThinkingExpandedIds] = useState<Set<string>>(() => new Set());
const [reasoningOpen, setReasoningOpen] = useState(false);
const reasoningButtonRef = useRef<HTMLButtonElement | null>(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<string[] | null>(null);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -868,6 +852,24 @@ 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();
// 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, 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
// visible. The renderer estimate is only shown until the first answer arrives --
Expand Down Expand Up @@ -1487,36 +1489,39 @@ export function ChatStripPanel() {
</span>
</button>
{reasoningLabel ? (
<button
ref={reasoningButtonRef}
type="button"
className={styles.reasoningBtn}
aria-label={t("chat.reasoningEffortLabel")}
aria-haspopup="menu"
aria-expanded={reasoningOpen}
onClick={toggleReasoningOpen}
>
<span className={styles.chip}>
<span className={styles.d} />
{reasoningLabel}
</span>
</button>
) : 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.
<Popover open={reasoningOpen} onOpenChange={setReasoningOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={styles.reasoningBtn}
aria-label={t("chat.reasoningEffortLabel")}
aria-haspopup="menu"
aria-expanded={reasoningOpen}
>
<span className={styles.chip}>
<span className={styles.d} />
{reasoningLabel}
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
animated={false}
className="w-auto border-0 bg-transparent p-0 shadow-none"
onEscapeKeyDown={(event) => event.stopPropagation()}
>
<div
role="menu"
style={{
position: "fixed",
left: reasoningMenuRect.left,
bottom: reasoningMenuRect.bottom,
minWidth: 160,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-md)",
boxShadow: "var(--elev-pop)",
padding: 4,
zIndex: 1000,
}}
>
{getReasoningEffortOptions(llmConfig?.provider ?? "").map((option) => (
Expand Down Expand Up @@ -1546,10 +1551,10 @@ export function ChatStripPanel() {
{option === currentReasoningEffort ? <Check size={12} /> : null}
</button>
))}
</div>,
document.body,
)
: null}
</div>
</PopoverContent>
</Popover>
) : null}
{modelPopoverOpen && modelPopoverRect && llmConfig ? (
<ModelQuickPopover
anchorRect={modelPopoverRect}
Expand Down
Loading