feat(thinking-selector): add keyboard popup menu

This commit is contained in:
2026-08-28 15:27:46 +02:00
parent d448152e7f
commit 087c177e8e
12 changed files with 569 additions and 185 deletions
+3 -3
View File
@@ -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"/);
+1
View File
@@ -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", ());
}
}
}
+3 -3
View File
@@ -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" }),
);
+21
View File
@@ -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\)/);
});
});
+13 -30
View File
@@ -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;
}
+182 -40
View File
@@ -1,40 +1,25 @@
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(
<ThinkingSelector
model={{
const model = {
reasoning: true,
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: "xhigh",
},
}}
value="medium"
onChange={onChange}
/>,
);
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(
<ThinkingSelector
model={{
};
const fullModel = {
reasoning: true,
thinkingLevelMap: {
minimal: "minimal",
@@ -43,22 +28,179 @@ it("keeps complete native labels available through XHigh", () => {
high: "high",
xhigh: "xhigh",
},
}}
value="medium"
onChange={vi.fn()}
};
describe("ThinkingSelector", () => {
it("uses reconciled model options and marks selected level", async () => {
const user = userEvent.setup();
render(
<ThinkingSelector model={model} value="medium" onChange={vi.fn()} />,
);
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(<ThinkingSelector model={model} value="off" onChange={onChange} />);
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(
<ThinkingSelector model={fullModel} value="medium" onChange={onChange} />,
);
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(
<ThinkingSelector model={fullModel} value="off" onChange={onChange} />,
);
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(<ThinkingSelector model={model} value="high" onChange={vi.fn()} />);
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(
<ThinkingSelector model={model} value="high" onChange={onChange} />,
);
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(
<ThinkingSelector
model={model}
value="high"
disabled
onChange={onChange}
/>,
);
expect(
within(screen.getByRole("combobox", { name: "Thinking" }))
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]);
});
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("uses disabled native select semantics", () => {
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
it("supports disabled and downward trigger placement", () => {
const { container } = render(
<ThinkingSelector
value="off"
disabled
placement="down"
onChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Thinking Off" })).toBeDisabled();
expect(
screen.getByRole("combobox", { name: "Thinking" }),
).toBeDisabled();
container.querySelector(".thinking-selector-down"),
).toBeInTheDocument();
});
});
+208 -13
View File
@@ -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<number | undefined>(undefined);
const typeaheadRef = useRef({ query: "", time: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 className={classes}>
<span>{label}</span>
<select
className={controlClassName}
aria-label={label}
value={selected}
<div className={classes}>
<span id={labelId}>{label}</span>
<div className="model-selector thinking-popup" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className={["model-trigger", "thinking-trigger", controlClassName]
.filter(Boolean)
.join(" ")}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? menuId : undefined}
aria-labelledby={`${labelId} ${valueId}`}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
onClick={() => {
if (disabledRef.current) return;
if (openRef.current) closeMenu();
else openMenu();
}}
>
<span id={valueId}>{selectedOption?.label ?? "Off"}</span>
</button>
{open && (
<div
id={menuId}
className="model-menu thinking-menu"
role="listbox"
aria-label={`${label} options`}
>
<div className="model-results thinking-options">
{options.map((option, index) => (
<button
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
className="model-option thinking-option"
role="option"
aria-selected={option.value === selected}
tabIndex={index === activeIndex ? 0 : -1}
value={option.value}
key={option.value}
onClick={() => selectOption(index)}
onKeyDown={(event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveOption(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveOption(-1);
break;
case "Home":
event.preventDefault();
setActiveIndex(0);
break;
case "End":
event.preventDefault();
setActiveIndex(options.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
selectOption(activeIndex);
break;
case "Escape":
event.preventDefault();
closeMenu(true);
break;
default:
if (
event.key.length === 1 &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
event.preventDefault();
typeahead(event.key.toLocaleLowerCase());
}
}
}}
>
{thinkingLevelOptions(model).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
</button>
))}
</select>
</label>
</div>
</div>
)}
</div>
</div>
);
}
+5 -5
View File
@@ -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),
.getAllByRole("option")
.map((option) => option.getAttribute("value")),
).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
});
it("packs session status between model controls and the composer", () => {
+74 -24
View File
@@ -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(<QuickPane />);
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(<QuickPane />);
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(<QuickPane />);
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<void>();
const tracker = bridgeMock([defaultModels, defaultModels]);
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "close_quick_runtime") return closing.promise;
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
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", {
+10 -6
View File
@@ -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() {
<div
className="quick-pane-titlebar"
onPointerDown={(event) => {
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() {
<ThinkingSelector
className="quick-pane-thinking"
controlClassName="quick-pane-toolbar-control"
placement="down"
model={selectedCatalogModel}
value={thinkingLevel}
disabled={catalogOverlayActive || runActive || chatReplacing}
+9 -25
View File
@@ -19,6 +19,7 @@ body {
.quick-pane {
position: relative;
container-type: size;
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -145,8 +146,8 @@ body {
}
.quick-pane .model-menu {
width: min(400px, calc(100vw - 24px));
max-height: min(350px, calc(100vh - 56px));
width: min(400px, calc(100cqw - 24px));
max-height: min(350px, calc(100cqh - 74px));
color: #e6e3df;
font-size: 11px;
font-weight: 500;
@@ -185,8 +186,7 @@ body {
display: none;
}
.quick-pane button,
.quick-pane select {
.quick-pane button {
box-sizing: border-box;
min-width: 0;
border: 0;
@@ -195,15 +195,6 @@ body {
font: inherit;
}
.quick-pane select {
width: 100%;
overflow: hidden;
color: #e6e3df;
background: #353535;
font-size: 11px;
text-overflow: ellipsis;
}
.quick-pane button {
flex: 0 0 auto;
color: #25180b;
@@ -216,24 +207,19 @@ body {
background: #ffc170;
}
.quick-pane button:disabled,
.quick-pane select:disabled {
.quick-pane button:disabled {
opacity: 0.58;
cursor: wait;
}
.quick-pane button:focus-visible,
.quick-pane select:focus-visible {
.quick-pane button:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane button.quick-pane-toolbar-control,
.quick-pane select.quick-pane-toolbar-control,
.quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane
.thinking-selector.thinking-selector
select.quick-pane-toolbar-control {
.quick-pane .thinking-trigger.quick-pane-toolbar-control {
box-sizing: border-box;
height: 27px;
min-width: 48px;
@@ -251,9 +237,7 @@ body {
}
.quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane
.thinking-selector.thinking-selector
select.quick-pane-toolbar-control {
.quick-pane .thinking-trigger.quick-pane-toolbar-control {
appearance: auto;
}
@@ -659,7 +643,7 @@ body {
gap: 2px;
}
.quick-pane select {
.quick-pane .thinking-trigger {
padding-inline: 5px;
font-size: 10px;
}
+14 -10
View File
@@ -1,4 +1,5 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationWorkspace } from "./components/ConversationWorkspace";
import QuickPane from "./quick-pane/QuickPane";
@@ -40,7 +41,7 @@ const settings: UiState = {
},
};
function options(selector: HTMLSelectElement) {
function options(selector: HTMLElement) {
return within(selector)
.getAllByRole("option")
.map((option) => 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),
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,
);
for (const selector of screen.getAllByRole("combobox", {
name: "Thinking",
}) as HTMLSelectElement[])
expect(options(selector)).toEqual(expected);
await user.click(trigger);
}
},
);
});