diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index f04b8b8..9d68c75 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -102,7 +102,7 @@ test("main Settings uses shared catalog selectors with provider derived from mod assert.match(persistence, /quickPane: current\.quickPane/); }); -test("Quick Pane keeps slash-containing model IDs atomic and renews catalog on focus", async () => { +test("Quick Pane keeps slash-containing model IDs atomic and renews only on pane show", async () => { const [pane, catalog] = await Promise.all([ readFile( new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), @@ -114,8 +114,8 @@ test("Quick Pane keeps slash-containing model IDs atomic and renews catalog on f assert.match(pane, /provider: submittedModel\.provider/); assert.match(pane, /modelId: submittedModel\.modelId/); assert.doesNotMatch(pane, /selectedModel\.split/); - assert.match(pane, /onFocusChanged[\s\S]*?void renewChatOnFocus\(\)/); - assert.match(pane, /if \(!event\.payload\) return;/); + assert.match(pane, /listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/); + assert.match(pane, /onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/); assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/); assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/); assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/); diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index aa43ab2..3ee974f 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -258,6 +258,7 @@ fn toggle_quick_pane(app: &AppHandle) { visibility.quick_pane_shown(); let _ = window.unminimize(); let _ = window.set_focus(); + let _ = app.emit("quick-pane-shown", ()); } } } diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index e69890f..e3edcdd 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -190,10 +190,10 @@ describe("App command progress and errors", () => { await user.click( screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }), ); - await user.selectOptions( - screen.getByRole("combobox", { name: "Default thinking" }), - "high", + await user.click( + screen.getByRole("button", { name: "Default thinking Medium" }), ); + await user.click(screen.getByRole("option", { name: "High" })); await user.click( screen.getByRole("button", { name: "Save Quick Pane settings" }), ); diff --git a/ui/src/components/Dropdown.css.test.ts b/ui/src/components/Dropdown.css.test.ts new file mode 100644 index 0000000..5f4f108 --- /dev/null +++ b/ui/src/components/Dropdown.css.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +const fs = await import(["node", "fs"].join(":")); +const modelCss = fs.readFileSync("src/components/ModelSelector.css", "utf8"); +const thinkingCss = fs.readFileSync("src/components/ThinkingSelector.css", "utf8"); +const quickPaneCss = fs.readFileSync("src/quick-pane/quick-pane.css", "utf8"); + +describe("dropdown CSS", () => { + it("keeps regular model menu capped with scrolling results", () => { + expect(modelCss).toMatch(/\.model-menu\s*\{[\s\S]*max-height:\s*min\(420px, calc\(100vh - 80px\)\)/); + expect(modelCss).toMatch(/\.model-results\s*\{[\s\S]*overflow-y:\s*auto/); + expect(modelCss).toMatch(/\.model-option\[aria-selected="true"\]/); + }); + + it("caps compact menus to Quick Pane container below titlebar and toolbar", () => { + expect(quickPaneCss).toMatch(/\.quick-pane\s*\{[\s\S]*container-type:\s*size/); + expect(quickPaneCss).toMatch(/\.quick-pane \.model-menu\s*\{[\s\S]*max-height:\s*min\(350px, calc\(100cqh - 74px\)\)/); + expect(quickPaneCss).toMatch(/\.quick-pane \.model-menu\s*\{[\s\S]*width:\s*min\(400px, calc\(100cqw - 24px\)\)/); + expect(thinkingCss).toMatch(/\.thinking-selector-down \.thinking-menu\s*\{[\s\S]*top:\s*calc\(100% \+ 6px\)/); + }); +}); diff --git a/ui/src/components/ThinkingSelector.css b/ui/src/components/ThinkingSelector.css index a09e518..f69157f 100644 --- a/ui/src/components/ThinkingSelector.css +++ b/ui/src/components/ThinkingSelector.css @@ -1,8 +1,9 @@ .thinking-selector { - display: flex; - min-width: 0; - align-items: center; + display: grid; + grid-template-columns: auto minmax(0, 1fr); gap: 6px; + align-items: center; + min-width: 0; color: #bbb7b0; font-size: 10px; font-weight: 750; @@ -10,37 +11,19 @@ text-transform: uppercase; } -.thinking-selector.thinking-selector select { - box-sizing: border-box; - width: 100%; - min-width: 0; - appearance: none; - color: #f2ece4; - color-scheme: dark; - padding: 5px 25px 5px 6px; - border: 1px solid #70522f; - border-radius: 4px; - background: #2c2a27 - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E") - no-repeat right 7px center; +.thinking-menu { + grid-template-rows: minmax(0, 1fr); } -.thinking-selector.thinking-selector select:hover { - border-color: #d88735; - background-color: #383129; +.thinking-selector-down .thinking-menu { + top: calc(100% + 6px); + bottom: auto; } -.thinking-selector.thinking-selector select:focus { - border-color: #f0a347; - box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22); +.thinking-options { + gap: 2px; } -.thinking-selector.thinking-selector select option { - background: #2c2a27; - color: #f2ece4; -} - -.thinking-selector.thinking-selector select option:checked { - background: #4b3420; - color: #fff2df; +.thinking-option { + font-size: inherit; } diff --git a/ui/src/components/ThinkingSelector.test.tsx b/ui/src/components/ThinkingSelector.test.tsx index d463a53..5b80fc6 100644 --- a/ui/src/components/ThinkingSelector.test.tsx +++ b/ui/src/components/ThinkingSelector.test.tsx @@ -1,64 +1,206 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { expect, it, vi } from "vitest"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import { ThinkingSelector } from "./ThinkingSelector"; -it("uses selected model thinking capabilities and reconciles unavailable values", () => { - const onChange = vi.fn(); - render( - , - ); +const model = { + reasoning: true, + thinkingLevelMap: { + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: "xhigh", + }, +}; +const fullModel = { + reasoning: true, + thinkingLevelMap: { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + }, +}; - const selector = screen.getByRole("combobox", { name: "Thinking" }); - expect(selector).toHaveValue("off"); - expect( - within(selector) - .getAllByRole("option") - .map((option) => option.textContent), - ).toEqual(["Off", "High"]); - fireEvent.change(selector, { target: { value: "high" } }); - expect(onChange).toHaveBeenCalledWith("high"); -}); - -it("keeps complete native labels available through XHigh", () => { - render( - , - ); - - expect( - within(screen.getByRole("combobox", { name: "Thinking" })) - .getAllByRole("option") - .map((option) => option.textContent), - ).toEqual(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]); -}); - -it("uses disabled native select semantics", () => { - render(); - expect( - screen.getByRole("combobox", { name: "Thinking" }), - ).toBeDisabled(); +describe("ThinkingSelector", () => { + it("uses reconciled model options and marks selected level", async () => { + const user = userEvent.setup(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Thinking Off" }); + await user.click(trigger); + const menu = screen.getByRole("listbox", { name: "Thinking options" }); + expect( + within(menu) + .getAllByRole("option") + .map((option) => option.textContent), + ).toEqual(["Off", "High", "XHigh"]); + expect(within(menu).getByRole("option", { name: "Off" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("selects an option, closes menu, and restores trigger focus", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const trigger = screen.getByRole("button", { name: "Thinking Off" }); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "High" })); + expect(onChange).toHaveBeenCalledWith("high"); + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it("roves options with arrows, Home, End, and Enter", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Thinking Medium" }); + await user.click(trigger); + const menu = screen.getByRole("listbox", { name: "Thinking options" }); + const medium = within(menu).getByRole("option", { name: "Medium" }); + await waitFor(() => expect(medium).toHaveFocus()); + expect(medium).toHaveAttribute("tabindex", "0"); + + await user.keyboard("{ArrowDown}"); + await waitFor(() => + expect(within(menu).getByRole("option", { name: "High" })).toHaveFocus(), + ); + await user.keyboard("{ArrowUp}{Home}"); + await waitFor(() => + expect(within(menu).getByRole("option", { name: "Off" })).toHaveFocus(), + ); + await user.keyboard("{End}"); + await waitFor(() => + expect(within(menu).getByRole("option", { name: "XHigh" })).toHaveFocus(), + ); + await user.keyboard("{Enter}"); + + expect(onChange).toHaveBeenCalledWith("xhigh"); + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it("selects active option with Space and cycles typeahead matches", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Thinking Off" }); + await user.click(trigger); + const menu = screen.getByRole("listbox", { name: "Thinking options" }); + await waitFor(() => + expect(within(menu).getByRole("option", { name: "Off" })).toHaveFocus(), + ); + await user.keyboard("m"); + await waitFor(() => + expect( + within(menu).getByRole("option", { name: "Minimal" }), + ).toHaveFocus(), + ); + await user.keyboard("m"); + await waitFor(() => + expect( + within(menu).getByRole("option", { name: "Medium" }), + ).toHaveFocus(), + ); + await user.keyboard(" "); + + expect(onChange).toHaveBeenCalledWith("medium"); + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it("closes on Escape with focus restore and outside pointer without closing pane", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole("button", { name: "Thinking High" }); + await user.click(trigger); + await waitFor(() => + expect(screen.getByRole("option", { name: "High" })).toHaveFocus(), + ); + await user.keyboard("{Escape}"); + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + await waitFor(() => expect(trigger).toHaveFocus()); + + await user.click(trigger); + fireEvent.pointerDown(document.body); + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + }); + + it("closes an open menu when disabled without restoring focus", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { rerender } = render( + , + ); + const trigger = screen.getByRole("button", { name: "Thinking High" }); + + await user.click(trigger); + const option = screen.getByRole("option", { name: "High" }); + await waitFor(() => expect(option).toHaveFocus()); + rerender( + , + ); + + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + expect(trigger).toBeDisabled(); + expect(trigger).not.toHaveFocus(); + fireEvent.click(option); + option.focus(); + fireEvent.keyDown(option, { key: "Enter" }); + fireEvent.click(option, { detail: 0 }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("supports disabled and downward trigger placement", () => { + const { container } = render( + , + ); + expect(screen.getByRole("button", { name: "Thinking Off" })).toBeDisabled(); + expect( + container.querySelector(".thinking-selector-down"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/src/components/ThinkingSelector.tsx b/ui/src/components/ThinkingSelector.tsx index ec5a5b8..24c9967 100644 --- a/ui/src/components/ThinkingSelector.tsx +++ b/ui/src/components/ThinkingSelector.tsx @@ -1,5 +1,7 @@ +import { useEffect, useId, useRef, useState } from "react"; import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog"; import type { Model } from "../workspace/types"; +import "./ModelSelector.css"; import "./ThinkingSelector.css"; type ThinkingSelectorProps = { @@ -7,6 +9,7 @@ type ThinkingSelectorProps = { label?: string; value: string; disabled?: boolean; + placement?: "up" | "down"; className?: string; controlClassName?: string; onChange: (level: string) => void; @@ -17,29 +20,221 @@ export function ThinkingSelector({ label = "Thinking", value, disabled = false, + placement = "up", className, controlClassName, onChange, }: ThinkingSelectorProps) { + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const openRef = useRef(false); + const disabledRef = useRef(disabled); + const focusFrameRef = useRef(undefined); + const typeaheadRef = useRef({ query: "", time: 0 }); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const optionRefs = useRef<(HTMLButtonElement | null)[]>([]); + disabledRef.current = disabled; + const labelId = useId(); + const valueId = useId(); + const menuId = useId(); + const options = thinkingLevelOptions(model); const selected = reconcileThinkingLevel(model, value); - const classes = ["thinking-selector", className].filter(Boolean).join(" "); + const selectedIndex = options.findIndex( + (option) => option.value === selected, + ); + const selectedOption = options[selectedIndex]; + const classes = [ + "thinking-selector", + `thinking-selector-${placement}`, + className, + ] + .filter(Boolean) + .join(" "); + const closeMenu = (restoreFocus = false) => { + if (!openRef.current) return; + openRef.current = false; + setOpen(false); + typeaheadRef.current = { query: "", time: 0 }; + if (restoreFocus) { + window.cancelAnimationFrame(focusFrameRef.current ?? 0); + focusFrameRef.current = window.requestAnimationFrame(() => { + focusFrameRef.current = undefined; + if (!disabledRef.current) triggerRef.current?.focus(); + }); + } + }; + const openMenu = () => { + if (disabledRef.current || openRef.current) return; + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + openRef.current = true; + setOpen(true); + }; + const selectOption = (index: number) => { + const option = options[index]; + if (!option || disabledRef.current) return; + onChange(option.value); + closeMenu(true); + }; + const moveActiveOption = (direction: 1 | -1) => { + setActiveIndex( + (index) => (index + direction + options.length) % options.length, + ); + }; + const typeahead = (key: string) => { + const now = Date.now(); + const previous = typeaheadRef.current; + let query = now - previous.time < 500 ? `${previous.query}${key}` : key; + let matches = options + .map((option, index) => ({ option, index })) + .filter(({ option }) => + option.label.toLocaleLowerCase().startsWith(query), + ); + if (!matches.length && query.length > 1) { + query = key; + matches = options + .map((option, index) => ({ option, index })) + .filter(({ option }) => + option.label.toLocaleLowerCase().startsWith(query), + ); + } + typeaheadRef.current = { query, time: now }; + if (!matches.length) return; + const next = matches.find(({ index }) => index > activeIndex) ?? matches[0]; + setActiveIndex(next.index); + }; + + useEffect(() => { + if (!disabled) return; + window.cancelAnimationFrame(focusFrameRef.current ?? 0); + focusFrameRef.current = undefined; + closeMenu(); + }, [disabled]); + + useEffect(() => { + return () => { + openRef.current = false; + window.cancelAnimationFrame(focusFrameRef.current ?? 0); + }; + }, []); + + useEffect(() => { + if (!open) return; + const focusFrame = window.requestAnimationFrame(() => + optionRefs.current[activeIndex]?.focus(), + ); + return () => window.cancelAnimationFrame(focusFrame); + }, [activeIndex, open]); + + useEffect(() => { + if (!open) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + closeMenu(true); + }; + const closeOutside = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) closeMenu(); + }; + window.addEventListener("keydown", closeOnEscape); + window.addEventListener("pointerdown", closeOutside); + return () => { + window.removeEventListener("keydown", closeOnEscape); + window.removeEventListener("pointerdown", closeOutside); + }; + }, [open]); return ( - +
+ {label} +
+ + {open && ( +
+
+ {options.map((option, index) => ( + + ))} +
+
+ )} +
+
); } diff --git a/ui/src/components/components.test.tsx b/ui/src/components/components.test.tsx index d1215b0..75fafbd 100644 --- a/ui/src/components/components.test.tsx +++ b/ui/src/components/components.test.tsx @@ -178,7 +178,7 @@ describe("multi-session navigation", () => { await user.click(screen.getByText("Tool result ยท read")); expect(details).toHaveAttribute("open"); }); - it("uses omitted-reasoning state model metadata when catalog lacks current model", () => { + it("uses omitted-reasoning state model metadata when catalog lacks current model", async () => { const runtime = runtimeView(summary("thinking")); runtime.state = { model: { @@ -199,12 +199,12 @@ describe("multi-session navigation", () => { onCommand={vi.fn()} />, ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Thinking Medium" })); expect( - Array.from( - screen - .getByRole("combobox", { name: "Thinking" }) - .querySelectorAll("option"), - ).map((option) => option.value), + screen + .getAllByRole("option") + .map((option) => option.getAttribute("value")), ).toEqual(["off", "minimal", "low", "medium", "xhigh"]); }); it("packs session status between model controls and the composer", () => { diff --git a/ui/src/quick-pane/QuickPane.test.tsx b/ui/src/quick-pane/QuickPane.test.tsx index b5f8fbf..459efc7 100644 --- a/ui/src/quick-pane/QuickPane.test.tsx +++ b/ui/src/quick-pane/QuickPane.test.tsx @@ -24,6 +24,9 @@ const mocks = vi.hoisted(() => ({ focusHandler: undefined as | ((event: { payload: boolean }) => void) | undefined, + shownHandler: undefined as + | ((event: { payload: undefined }) => void) + | undefined, })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); @@ -218,8 +221,14 @@ describe("QuickPane", () => { vi.restoreAllMocks(); vi.clearAllMocks(); mocks.focusHandler = undefined; + mocks.shownHandler = undefined; mocks.startDragging.mockResolvedValue(undefined); - mocks.listen.mockResolvedValue(vi.fn()); + mocks.listen.mockImplementation( + (event: string, handler: (event: { payload: undefined }) => void) => { + if (event === "quick-pane-shown") mocks.shownHandler = handler; + return Promise.resolve(vi.fn()); + }, + ); mocks.onFocusChanged.mockImplementation((handler) => { mocks.focusHandler = handler; return Promise.resolve(vi.fn()); @@ -245,16 +254,12 @@ describe("QuickPane", () => { bridgeMock([defaultModels]); const { container } = render(); const model = await preparedTrigger(); - const thinking = screen.getByRole("combobox", { name: "Thinking" }); + const thinking = screen.getByRole("button", { name: "Thinking Medium" }); const close = screen.getByRole("button", { name: "Close Quick Pane" }); for (const control of [model, thinking, close]) expect(control).toHaveClass("quick-pane-toolbar-control"); - expect( - within(thinking) - .getAllByRole("option") - .map((option) => option.textContent), - ).toEqual(["Off", "Minimal", "Low", "Medium", "High"]); + expect(thinking).toHaveTextContent("Medium"); expect( within(container.querySelector(".quick-pane-toolbar")!).queryByRole( "button", @@ -270,7 +275,7 @@ describe("QuickPane", () => { const toolbar = container.querySelector(".quick-pane-toolbar")!; const controls = [ await preparedTrigger(), - screen.getByRole("combobox", { name: "Thinking" }), + screen.getByRole("button", { name: "Thinking Medium" }), screen.getByRole("button", { name: "Close Quick Pane" }), ]; @@ -291,7 +296,9 @@ describe("QuickPane", () => { fireEvent.pointerDown( screen.getByRole("button", { name: "Close Quick Pane" }), ); - fireEvent.pointerDown(screen.getByRole("combobox", { name: "Thinking" })); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Thinking Medium" }), + ); expect(mocks.startDragging).toHaveBeenCalledTimes(1); }); @@ -442,9 +449,9 @@ describe("QuickPane", () => { }); render(); await preparedTrigger(); - expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveValue( - "high", - ); + expect( + screen.getByRole("button", { name: "Thinking High" }), + ).toBeInTheDocument(); await ask("Inspect"); await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { @@ -474,14 +481,14 @@ describe("QuickPane", () => { ); render(); await preparedTrigger(); - const thinking = screen.getByRole("combobox", { name: "Thinking" }); + const thinking = screen.getByRole("button", { name: "Thinking Off" }); + await userEvent.click(thinking); expect( - within(thinking) + within(screen.getByRole("listbox", { name: "Thinking options" })) .getAllByRole("option") .map((option) => option.textContent), ).toEqual(["Off", "High"]); - expect(thinking).toHaveValue("off"); - await userEvent.selectOptions(thinking, "high"); + await userEvent.click(screen.getByRole("option", { name: "High" })); await ask("Inspect"); await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { @@ -491,6 +498,34 @@ describe("QuickPane", () => { ); }); + it("closes thinking menu while Ctrl+N replaces chat", async () => { + const closing = deferred(); + const tracker = bridgeMock([defaultModels, defaultModels]); + const baseImplementation = mocks.invoke.getMockImplementation(); + mocks.invoke.mockImplementation( + (command: string, payload?: Record) => { + if (command === "close_quick_runtime") return closing.promise; + return baseImplementation?.(command, payload); + }, + ); + render(); + await preparedTrigger(); + const thinking = screen.getByRole("button", { name: "Thinking Medium" }); + await userEvent.click(thinking); + expect( + screen.getByRole("listbox", { name: "Thinking options" }), + ).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "n", ctrlKey: true, cancelable: true }); + await waitFor(() => expect(thinking).toBeDisabled()); + expect( + screen.queryByRole("listbox", { name: "Thinking options" }), + ).toBeNull(); + + await act(async () => closing.resolve()); + await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); + }); + it("keeps Abort active-only and Renew idle after an exchange", async () => { let streaming = true; const tracker = bridgeMock([defaultModels, defaultModels], () => @@ -1422,7 +1457,13 @@ describe("QuickPane fresh runtime catalog", () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - mocks.listen.mockResolvedValue(vi.fn()); + mocks.shownHandler = undefined; + mocks.listen.mockImplementation( + (event: string, handler: (event: { payload: undefined }) => void) => { + if (event === "quick-pane-shown") mocks.shownHandler = handler; + return Promise.resolve(vi.fn()); + }, + ); mocks.onFocusChanged.mockImplementation((handler) => { mocks.focusHandler = handler; return Promise.resolve(vi.fn()); @@ -1488,7 +1529,7 @@ describe("QuickPane fresh runtime catalog", () => { ); await act(async () => mocks.focusHandler?.({ payload: false })); now += 180_000; - await act(async () => mocks.focusHandler?.({ payload: true })); + await act(async () => mocks.shownHandler?.({ payload: undefined })); expect(tracker.runtimeCount()).toBe(1); expect(trigger).toBeInTheDocument(); @@ -1564,7 +1605,7 @@ describe("QuickPane fresh runtime catalog", () => { ), ); - await act(async () => mocks.focusHandler?.({ payload: true })); + await act(async () => mocks.shownHandler?.({ payload: undefined })); expect(await preparedTrigger()).toBeInTheDocument(); await act(async () => firstSnapshot.resolve(runtimeSnapshot(defaultModels)), @@ -1575,7 +1616,7 @@ describe("QuickPane fresh runtime catalog", () => { expect(activeRuntimeIds).toEqual(new Set(["runtime-1", "runtime-2"])); now += 180_001; - await act(async () => mocks.focusHandler?.({ payload: true })); + await act(async () => mocks.shownHandler?.({ payload: undefined })); await waitFor(() => expect(runtimeCount).toBe(3)); expect(firstCloseAttempts).toBe(2); expect(activeRuntimeIds).toEqual(new Set(["runtime-3"])); @@ -1660,7 +1701,7 @@ describe("QuickPane fresh runtime catalog", () => { expect(screen.queryByText("Partial")).toBeNull(); }); - it("renews a stale idle chat when reopened after three minutes", async () => { + it("renews stale idle chat only after Quick Pane show, never focus while open", async () => { let now = 1_000_000; vi.spyOn(Date, "now").mockImplementation(() => now); const freshModels: Model[] = [ @@ -1682,12 +1723,21 @@ describe("QuickPane fresh runtime catalog", () => { ).not.toHaveAttribute("readonly"), ); + now += 180_001; + fireEvent.change(screen.getByRole("textbox", { name: "Request" }), { + target: { value: "Still open" }, + }); + await act(async () => mocks.focusHandler?.({ payload: false })); + await act(async () => mocks.focusHandler?.({ payload: true })); + expect(tracker.runtimeCount()).toBe(1); + expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }); + await userEvent.click( screen.getByRole("button", { name: "Close Quick Pane" }), ); - await act(async () => mocks.focusHandler?.({ payload: false })); - now += 180_001; - await act(async () => mocks.focusHandler?.({ payload: true })); + await act(async () => mocks.shownHandler?.({ payload: undefined })); await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { diff --git a/ui/src/quick-pane/QuickPane.tsx b/ui/src/quick-pane/QuickPane.tsx index 560c6ed..4497dbe 100644 --- a/ui/src/quick-pane/QuickPane.tsx +++ b/ui/src/quick-pane/QuickPane.tsx @@ -706,7 +706,7 @@ export default function QuickPane() { [clearChatState, clearRuntimeIfCurrent, closeRuntime, prepareRuntime], ); - const renewChatOnFocus = useCallback(async () => { + const renewChatOnShow = useCallback(async () => { if (requestActiveRef.current || submissionStartingRef.current) return; if (!runtimeRef.current) { clearChatState(false); @@ -832,6 +832,10 @@ export default function QuickPane() { void prepareRuntime(configuredModel); }, ); + const paneShownListener = listen("quick-pane-shown", () => { + focusInput(); + void renewChatOnShow(); + }); const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Tab") inputFocusIntentRef.current = "keyboard"; if (catalogOverlayActive) { @@ -856,20 +860,19 @@ export default function QuickPane() { }; window.addEventListener("keydown", onKeyDown); const focusListener = paneWindow.onFocusChanged((event) => { - if (!event.payload) return; - focusInput(); - void renewChatOnFocus(); + if (event.payload) focusInput(); }); return () => { window.removeEventListener("keydown", onKeyDown); void settingsListener.then((unlisten) => unlisten()); + void paneShownListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten()); }; }, [ catalogOverlayActive, dismissPane, prepareRuntime, - renewChatOnFocus, + renewChatOnShow, replaceChat, ]); @@ -1138,7 +1141,7 @@ export default function QuickPane() {
{ - if (event.button !== 0) return; + if (event.button && event.button !== 0) return; void getCurrentWindow() .startDragging() .catch(() => undefined); @@ -1176,6 +1179,7 @@ export default function QuickPane() { option.getAttribute("value")); @@ -127,15 +128,18 @@ describe("thinking selector parity", () => { "matches Pi %s semantics in Conversation Workspace and Quick Pane", async (_name, metadata, expected) => { renderSurfaces({ provider: "provider", id: "model", ...metadata }); - await waitFor(() => - expect( - screen.getAllByRole("combobox", { name: "Thinking" }), - ).toHaveLength(2), - ); - for (const selector of screen.getAllByRole("combobox", { - name: "Thinking", - }) as HTMLSelectElement[]) - expect(options(selector)).toEqual(expected); + const user = userEvent.setup(); + const triggers = await screen.findAllByRole("button", { + name: /Thinking /, + }); + expect(triggers).toHaveLength(2); + for (const trigger of triggers) { + await user.click(trigger); + expect(options(screen.getByRole("listbox", { name: "Thinking options" }))).toEqual( + expected, + ); + await user.click(trigger); + } }, ); });