feat(thinking-selector): add keyboard popup menu
This commit is contained in:
@@ -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"/);
|
||||
|
||||
@@ -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
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<ThinkingSelector
|
||||
model={{
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: "high",
|
||||
},
|
||||
}}
|
||||
value="medium"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThinkingSelector
|
||||
model={{
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: "minimal",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
},
|
||||
}}
|
||||
value="medium"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Thinking" }),
|
||||
).toBeDisabled();
|
||||
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(
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
>
|
||||
{thinkingLevelOptions(model).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<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}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user