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/); 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([ const [pane, catalog] = await Promise.all([
readFile( readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), 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, /provider: submittedModel\.provider/);
assert.match(pane, /modelId: submittedModel\.modelId/); assert.match(pane, /modelId: submittedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/); assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /onFocusChanged[\s\S]*?void renewChatOnFocus\(\)/); assert.match(pane, /listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/);
assert.match(pane, /if \(!event\.payload\) return;/); assert.match(pane, /onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/);
assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/); assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/);
assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/); assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/);
assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/); 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(); visibility.quick_pane_shown();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); 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( await user.click(
screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }), screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }),
); );
await user.selectOptions( await user.click(
screen.getByRole("combobox", { name: "Default thinking" }), screen.getByRole("button", { name: "Default thinking Medium" }),
"high",
); );
await user.click(screen.getByRole("option", { name: "High" }));
await user.click( await user.click(
screen.getByRole("button", { name: "Save Quick Pane settings" }), 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 { .thinking-selector {
display: flex; display: grid;
min-width: 0; grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 6px; gap: 6px;
align-items: center;
min-width: 0;
color: #bbb7b0; color: #bbb7b0;
font-size: 10px; font-size: 10px;
font-weight: 750; font-weight: 750;
@@ -10,37 +11,19 @@
text-transform: uppercase; text-transform: uppercase;
} }
.thinking-selector.thinking-selector select { .thinking-menu {
box-sizing: border-box; grid-template-rows: minmax(0, 1fr);
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-selector.thinking-selector select:hover { .thinking-selector-down .thinking-menu {
border-color: #d88735; top: calc(100% + 6px);
background-color: #383129; bottom: auto;
} }
.thinking-selector.thinking-selector select:focus { .thinking-options {
border-color: #f0a347; gap: 2px;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
} }
.thinking-selector.thinking-selector select option { .thinking-option {
background: #2c2a27; font-size: inherit;
color: #f2ece4;
}
.thinking-selector.thinking-selector select option:checked {
background: #4b3420;
color: #fff2df;
} }
+202 -60
View File
@@ -1,64 +1,206 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; import {
import { expect, it, vi } from "vitest"; 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"; import { ThinkingSelector } from "./ThinkingSelector";
it("uses selected model thinking capabilities and reconciles unavailable values", () => { const model = {
const onChange = vi.fn(); reasoning: true,
render( thinkingLevelMap: {
<ThinkingSelector minimal: null,
model={{ low: null,
reasoning: true, medium: null,
thinkingLevelMap: { high: "high",
minimal: null, xhigh: "xhigh",
low: null, },
medium: null, };
high: "high", const fullModel = {
}, reasoning: true,
}} thinkingLevelMap: {
value="medium" minimal: "minimal",
onChange={onChange} low: "low",
/>, medium: "medium",
); high: "high",
xhigh: "xhigh",
},
};
const selector = screen.getByRole("combobox", { name: "Thinking" }); describe("ThinkingSelector", () => {
expect(selector).toHaveValue("off"); it("uses reconciled model options and marks selected level", async () => {
expect( const user = userEvent.setup();
within(selector) render(
.getAllByRole("option") <ThinkingSelector model={model} value="medium" onChange={vi.fn()} />,
.map((option) => option.textContent), );
).toEqual(["Off", "High"]);
fireEvent.change(selector, { target: { value: "high" } }); const trigger = screen.getByRole("button", { name: "Thinking Off" });
expect(onChange).toHaveBeenCalledWith("high"); await user.click(trigger);
}); const menu = screen.getByRole("listbox", { name: "Thinking options" });
expect(
it("keeps complete native labels available through XHigh", () => { within(menu)
render( .getAllByRole("option")
<ThinkingSelector .map((option) => option.textContent),
model={{ ).toEqual(["Off", "High", "XHigh"]);
reasoning: true, expect(within(menu).getByRole("option", { name: "Off" })).toHaveAttribute(
thinkingLevelMap: { "aria-selected",
minimal: "minimal", "true",
low: "low", );
medium: "medium", });
high: "high",
xhigh: "xhigh", it("selects an option, closes menu, and restores trigger focus", async () => {
}, const user = userEvent.setup();
}} const onChange = vi.fn();
value="medium" render(<ThinkingSelector model={model} value="off" onChange={onChange} />);
onChange={vi.fn()}
/>, const trigger = screen.getByRole("button", { name: "Thinking Off" });
); await user.click(trigger);
await user.click(screen.getByRole("option", { name: "High" }));
expect( expect(onChange).toHaveBeenCalledWith("high");
within(screen.getByRole("combobox", { name: "Thinking" })) expect(
.getAllByRole("option") screen.queryByRole("listbox", { name: "Thinking options" }),
.map((option) => option.textContent), ).toBeNull();
).toEqual(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]); await waitFor(() => expect(trigger).toHaveFocus());
}); });
it("uses disabled native select semantics", () => { it("roves options with arrows, Home, End, and Enter", async () => {
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />); const user = userEvent.setup();
expect( const onChange = vi.fn();
screen.getByRole("combobox", { name: "Thinking" }), render(
).toBeDisabled(); <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(
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(
<ThinkingSelector
value="off"
disabled
placement="down"
onChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Thinking Off" })).toBeDisabled();
expect(
container.querySelector(".thinking-selector-down"),
).toBeInTheDocument();
});
}); });
+212 -17
View File
@@ -1,5 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog"; import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog";
import type { Model } from "../workspace/types"; import type { Model } from "../workspace/types";
import "./ModelSelector.css";
import "./ThinkingSelector.css"; import "./ThinkingSelector.css";
type ThinkingSelectorProps = { type ThinkingSelectorProps = {
@@ -7,6 +9,7 @@ type ThinkingSelectorProps = {
label?: string; label?: string;
value: string; value: string;
disabled?: boolean; disabled?: boolean;
placement?: "up" | "down";
className?: string; className?: string;
controlClassName?: string; controlClassName?: string;
onChange: (level: string) => void; onChange: (level: string) => void;
@@ -17,29 +20,221 @@ export function ThinkingSelector({
label = "Thinking", label = "Thinking",
value, value,
disabled = false, disabled = false,
placement = "up",
className, className,
controlClassName, controlClassName,
onChange, onChange,
}: ThinkingSelectorProps) { }: 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 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 ( return (
<label className={classes}> <div className={classes}>
<span>{label}</span> <span id={labelId}>{label}</span>
<select <div className="model-selector thinking-popup" ref={rootRef}>
className={controlClassName} <button
aria-label={label} ref={triggerRef}
value={selected} type="button"
disabled={disabled} className={["model-trigger", "thinking-trigger", controlClassName]
onChange={(event) => onChange(event.currentTarget.value)} .filter(Boolean)
> .join(" ")}
{thinkingLevelOptions(model).map((option) => ( aria-expanded={open}
<option key={option.value} value={option.value}> aria-haspopup="listbox"
{option.label} aria-controls={open ? menuId : undefined}
</option> aria-labelledby={`${labelId} ${valueId}`}
))} disabled={disabled}
</select> onClick={() => {
</label> 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());
}
}
}}
>
{option.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
); );
} }
+6 -6
View File
@@ -178,7 +178,7 @@ describe("multi-session navigation", () => {
await user.click(screen.getByText("Tool result · read")); await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open"); 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")); const runtime = runtimeView(summary("thinking"));
runtime.state = { runtime.state = {
model: { model: {
@@ -199,12 +199,12 @@ describe("multi-session navigation", () => {
onCommand={vi.fn()} onCommand={vi.fn()}
/>, />,
); );
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Thinking Medium" }));
expect( expect(
Array.from( screen
screen .getAllByRole("option")
.getByRole("combobox", { name: "Thinking" }) .map((option) => option.getAttribute("value")),
.querySelectorAll("option"),
).map((option) => option.value),
).toEqual(["off", "minimal", "low", "medium", "xhigh"]); ).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
}); });
it("packs session status between model controls and the composer", () => { 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 focusHandler: undefined as
| ((event: { payload: boolean }) => void) | ((event: { payload: boolean }) => void)
| undefined, | undefined,
shownHandler: undefined as
| ((event: { payload: undefined }) => void)
| undefined,
})); }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
@@ -218,8 +221,14 @@ describe("QuickPane", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks(); vi.clearAllMocks();
mocks.focusHandler = undefined; mocks.focusHandler = undefined;
mocks.shownHandler = undefined;
mocks.startDragging.mockResolvedValue(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.onFocusChanged.mockImplementation((handler) => {
mocks.focusHandler = handler; mocks.focusHandler = handler;
return Promise.resolve(vi.fn()); return Promise.resolve(vi.fn());
@@ -245,16 +254,12 @@ describe("QuickPane", () => {
bridgeMock([defaultModels]); bridgeMock([defaultModels]);
const { container } = render(<QuickPane />); const { container } = render(<QuickPane />);
const model = await preparedTrigger(); 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" }); const close = screen.getByRole("button", { name: "Close Quick Pane" });
for (const control of [model, thinking, close]) for (const control of [model, thinking, close])
expect(control).toHaveClass("quick-pane-toolbar-control"); expect(control).toHaveClass("quick-pane-toolbar-control");
expect( expect(thinking).toHaveTextContent("Medium");
within(thinking)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "Minimal", "Low", "Medium", "High"]);
expect( expect(
within(container.querySelector(".quick-pane-toolbar")!).queryByRole( within(container.querySelector(".quick-pane-toolbar")!).queryByRole(
"button", "button",
@@ -270,7 +275,7 @@ describe("QuickPane", () => {
const toolbar = container.querySelector(".quick-pane-toolbar")!; const toolbar = container.querySelector(".quick-pane-toolbar")!;
const controls = [ const controls = [
await preparedTrigger(), await preparedTrigger(),
screen.getByRole("combobox", { name: "Thinking" }), screen.getByRole("button", { name: "Thinking Medium" }),
screen.getByRole("button", { name: "Close Quick Pane" }), screen.getByRole("button", { name: "Close Quick Pane" }),
]; ];
@@ -291,7 +296,9 @@ describe("QuickPane", () => {
fireEvent.pointerDown( fireEvent.pointerDown(
screen.getByRole("button", { name: "Close Quick Pane" }), 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); expect(mocks.startDragging).toHaveBeenCalledTimes(1);
}); });
@@ -442,9 +449,9 @@ describe("QuickPane", () => {
}); });
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveValue( expect(
"high", screen.getByRole("button", { name: "Thinking High" }),
); ).toBeInTheDocument();
await ask("Inspect"); await ask("Inspect");
await waitFor(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", {
@@ -474,14 +481,14 @@ describe("QuickPane", () => {
); );
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
const thinking = screen.getByRole("combobox", { name: "Thinking" }); const thinking = screen.getByRole("button", { name: "Thinking Off" });
await userEvent.click(thinking);
expect( expect(
within(thinking) within(screen.getByRole("listbox", { name: "Thinking options" }))
.getAllByRole("option") .getAllByRole("option")
.map((option) => option.textContent), .map((option) => option.textContent),
).toEqual(["Off", "High"]); ).toEqual(["Off", "High"]);
expect(thinking).toHaveValue("off"); await userEvent.click(screen.getByRole("option", { name: "High" }));
await userEvent.selectOptions(thinking, "high");
await ask("Inspect"); await ask("Inspect");
await waitFor(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { 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 () => { it("keeps Abort active-only and Renew idle after an exchange", async () => {
let streaming = true; let streaming = true;
const tracker = bridgeMock([defaultModels, defaultModels], () => const tracker = bridgeMock([defaultModels, defaultModels], () =>
@@ -1422,7 +1457,13 @@ describe("QuickPane fresh runtime catalog", () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks(); 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.onFocusChanged.mockImplementation((handler) => {
mocks.focusHandler = handler; mocks.focusHandler = handler;
return Promise.resolve(vi.fn()); return Promise.resolve(vi.fn());
@@ -1488,7 +1529,7 @@ describe("QuickPane fresh runtime catalog", () => {
); );
await act(async () => mocks.focusHandler?.({ payload: false })); await act(async () => mocks.focusHandler?.({ payload: false }));
now += 180_000; now += 180_000;
await act(async () => mocks.focusHandler?.({ payload: true })); await act(async () => mocks.shownHandler?.({ payload: undefined }));
expect(tracker.runtimeCount()).toBe(1); expect(tracker.runtimeCount()).toBe(1);
expect(trigger).toBeInTheDocument(); 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(); expect(await preparedTrigger()).toBeInTheDocument();
await act(async () => await act(async () =>
firstSnapshot.resolve(runtimeSnapshot(defaultModels)), firstSnapshot.resolve(runtimeSnapshot(defaultModels)),
@@ -1575,7 +1616,7 @@ describe("QuickPane fresh runtime catalog", () => {
expect(activeRuntimeIds).toEqual(new Set(["runtime-1", "runtime-2"])); expect(activeRuntimeIds).toEqual(new Set(["runtime-1", "runtime-2"]));
now += 180_001; now += 180_001;
await act(async () => mocks.focusHandler?.({ payload: true })); await act(async () => mocks.shownHandler?.({ payload: undefined }));
await waitFor(() => expect(runtimeCount).toBe(3)); await waitFor(() => expect(runtimeCount).toBe(3));
expect(firstCloseAttempts).toBe(2); expect(firstCloseAttempts).toBe(2);
expect(activeRuntimeIds).toEqual(new Set(["runtime-3"])); expect(activeRuntimeIds).toEqual(new Set(["runtime-3"]));
@@ -1660,7 +1701,7 @@ describe("QuickPane fresh runtime catalog", () => {
expect(screen.queryByText("Partial")).toBeNull(); 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; let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now); vi.spyOn(Date, "now").mockImplementation(() => now);
const freshModels: Model[] = [ const freshModels: Model[] = [
@@ -1682,12 +1723,21 @@ describe("QuickPane fresh runtime catalog", () => {
).not.toHaveAttribute("readonly"), ).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( await userEvent.click(
screen.getByRole("button", { name: "Close Quick Pane" }), screen.getByRole("button", { name: "Close Quick Pane" }),
); );
await act(async () => mocks.focusHandler?.({ payload: false })); await act(async () => mocks.shownHandler?.({ payload: undefined }));
now += 180_001;
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
+10 -6
View File
@@ -706,7 +706,7 @@ export default function QuickPane() {
[clearChatState, clearRuntimeIfCurrent, closeRuntime, prepareRuntime], [clearChatState, clearRuntimeIfCurrent, closeRuntime, prepareRuntime],
); );
const renewChatOnFocus = useCallback(async () => { const renewChatOnShow = useCallback(async () => {
if (requestActiveRef.current || submissionStartingRef.current) return; if (requestActiveRef.current || submissionStartingRef.current) return;
if (!runtimeRef.current) { if (!runtimeRef.current) {
clearChatState(false); clearChatState(false);
@@ -832,6 +832,10 @@ export default function QuickPane() {
void prepareRuntime(configuredModel); void prepareRuntime(configuredModel);
}, },
); );
const paneShownListener = listen("quick-pane-shown", () => {
focusInput();
void renewChatOnShow();
});
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") inputFocusIntentRef.current = "keyboard"; if (event.key === "Tab") inputFocusIntentRef.current = "keyboard";
if (catalogOverlayActive) { if (catalogOverlayActive) {
@@ -856,20 +860,19 @@ export default function QuickPane() {
}; };
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
const focusListener = paneWindow.onFocusChanged((event) => { const focusListener = paneWindow.onFocusChanged((event) => {
if (!event.payload) return; if (event.payload) focusInput();
focusInput();
void renewChatOnFocus();
}); });
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
void settingsListener.then((unlisten) => unlisten()); void settingsListener.then((unlisten) => unlisten());
void paneShownListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten());
}; };
}, [ }, [
catalogOverlayActive, catalogOverlayActive,
dismissPane, dismissPane,
prepareRuntime, prepareRuntime,
renewChatOnFocus, renewChatOnShow,
replaceChat, replaceChat,
]); ]);
@@ -1138,7 +1141,7 @@ export default function QuickPane() {
<div <div
className="quick-pane-titlebar" className="quick-pane-titlebar"
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button !== 0) return; if (event.button && event.button !== 0) return;
void getCurrentWindow() void getCurrentWindow()
.startDragging() .startDragging()
.catch(() => undefined); .catch(() => undefined);
@@ -1176,6 +1179,7 @@ export default function QuickPane() {
<ThinkingSelector <ThinkingSelector
className="quick-pane-thinking" className="quick-pane-thinking"
controlClassName="quick-pane-toolbar-control" controlClassName="quick-pane-toolbar-control"
placement="down"
model={selectedCatalogModel} model={selectedCatalogModel}
value={thinkingLevel} value={thinkingLevel}
disabled={catalogOverlayActive || runActive || chatReplacing} disabled={catalogOverlayActive || runActive || chatReplacing}
+9 -25
View File
@@ -19,6 +19,7 @@ body {
.quick-pane { .quick-pane {
position: relative; position: relative;
container-type: size;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -145,8 +146,8 @@ body {
} }
.quick-pane .model-menu { .quick-pane .model-menu {
width: min(400px, calc(100vw - 24px)); width: min(400px, calc(100cqw - 24px));
max-height: min(350px, calc(100vh - 56px)); max-height: min(350px, calc(100cqh - 74px));
color: #e6e3df; color: #e6e3df;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
@@ -185,8 +186,7 @@ body {
display: none; display: none;
} }
.quick-pane button, .quick-pane button {
.quick-pane select {
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
border: 0; border: 0;
@@ -195,15 +195,6 @@ body {
font: inherit; font: inherit;
} }
.quick-pane select {
width: 100%;
overflow: hidden;
color: #e6e3df;
background: #353535;
font-size: 11px;
text-overflow: ellipsis;
}
.quick-pane button { .quick-pane button {
flex: 0 0 auto; flex: 0 0 auto;
color: #25180b; color: #25180b;
@@ -216,24 +207,19 @@ body {
background: #ffc170; background: #ffc170;
} }
.quick-pane button:disabled, .quick-pane button:disabled {
.quick-pane select:disabled {
opacity: 0.58; opacity: 0.58;
cursor: wait; cursor: wait;
} }
.quick-pane button:focus-visible, .quick-pane button:focus-visible {
.quick-pane select:focus-visible {
outline: 3px solid #f0a347; outline: 3px solid #f0a347;
outline-offset: 2px; outline-offset: 2px;
} }
.quick-pane button.quick-pane-toolbar-control, .quick-pane button.quick-pane-toolbar-control,
.quick-pane select.quick-pane-toolbar-control,
.quick-pane .model-trigger.quick-pane-toolbar-control, .quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane .quick-pane .thinking-trigger.quick-pane-toolbar-control {
.thinking-selector.thinking-selector
select.quick-pane-toolbar-control {
box-sizing: border-box; box-sizing: border-box;
height: 27px; height: 27px;
min-width: 48px; min-width: 48px;
@@ -251,9 +237,7 @@ body {
} }
.quick-pane .model-trigger.quick-pane-toolbar-control, .quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane .quick-pane .thinking-trigger.quick-pane-toolbar-control {
.thinking-selector.thinking-selector
select.quick-pane-toolbar-control {
appearance: auto; appearance: auto;
} }
@@ -659,7 +643,7 @@ body {
gap: 2px; gap: 2px;
} }
.quick-pane select { .quick-pane .thinking-trigger {
padding-inline: 5px; padding-inline: 5px;
font-size: 10px; font-size: 10px;
} }
+15 -11
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 { beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationWorkspace } from "./components/ConversationWorkspace"; import { ConversationWorkspace } from "./components/ConversationWorkspace";
import QuickPane from "./quick-pane/QuickPane"; import QuickPane from "./quick-pane/QuickPane";
@@ -40,7 +41,7 @@ const settings: UiState = {
}, },
}; };
function options(selector: HTMLSelectElement) { function options(selector: HTMLElement) {
return within(selector) return within(selector)
.getAllByRole("option") .getAllByRole("option")
.map((option) => option.getAttribute("value")); .map((option) => option.getAttribute("value"));
@@ -127,15 +128,18 @@ describe("thinking selector parity", () => {
"matches Pi %s semantics in Conversation Workspace and Quick Pane", "matches Pi %s semantics in Conversation Workspace and Quick Pane",
async (_name, metadata, expected) => { async (_name, metadata, expected) => {
renderSurfaces({ provider: "provider", id: "model", ...metadata }); renderSurfaces({ provider: "provider", id: "model", ...metadata });
await waitFor(() => const user = userEvent.setup();
expect( const triggers = await screen.findAllByRole("button", {
screen.getAllByRole("combobox", { name: "Thinking" }), name: /Thinking /,
).toHaveLength(2), });
); expect(triggers).toHaveLength(2);
for (const selector of screen.getAllByRole("combobox", { for (const trigger of triggers) {
name: "Thinking", await user.click(trigger);
}) as HTMLSelectElement[]) expect(options(screen.getByRole("listbox", { name: "Thinking options" }))).toEqual(
expect(options(selector)).toEqual(expected); expected,
);
await user.click(trigger);
}
}, },
); );
}); });