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/);
|
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"/);
|
||||||
|
|||||||
@@ -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
@@ -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" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
.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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,25 @@
|
|||||||
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();
|
|
||||||
render(
|
|
||||||
<ThinkingSelector
|
|
||||||
model={{
|
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
thinkingLevelMap: {
|
thinkingLevelMap: {
|
||||||
minimal: null,
|
minimal: null,
|
||||||
low: null,
|
low: null,
|
||||||
medium: null,
|
medium: null,
|
||||||
high: "high",
|
high: "high",
|
||||||
|
xhigh: "xhigh",
|
||||||
},
|
},
|
||||||
}}
|
};
|
||||||
value="medium"
|
const fullModel = {
|
||||||
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={{
|
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
thinkingLevelMap: {
|
thinkingLevelMap: {
|
||||||
minimal: "minimal",
|
minimal: "minimal",
|
||||||
@@ -43,22 +28,179 @@ it("keeps complete native labels available through XHigh", () => {
|
|||||||
high: "high",
|
high: "high",
|
||||||
xhigh: "xhigh",
|
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(
|
expect(
|
||||||
within(screen.getByRole("combobox", { name: "Thinking" }))
|
screen.queryByRole("listbox", { name: "Thinking options" }),
|
||||||
.getAllByRole("option")
|
).toBeNull();
|
||||||
.map((option) => option.textContent),
|
expect(trigger).toBeDisabled();
|
||||||
).toEqual(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]);
|
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", () => {
|
it("supports disabled and downward trigger placement", () => {
|
||||||
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
|
const { container } = render(
|
||||||
|
<ThinkingSelector
|
||||||
|
value="off"
|
||||||
|
disabled
|
||||||
|
placement="down"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Thinking Off" })).toBeDisabled();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("combobox", { name: "Thinking" }),
|
container.querySelector(".thinking-selector-down"),
|
||||||
).toBeDisabled();
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
return (
|
);
|
||||||
<label className={classes}>
|
const selectedOption = options[selectedIndex];
|
||||||
<span>{label}</span>
|
const classes = [
|
||||||
<select
|
"thinking-selector",
|
||||||
className={controlClassName}
|
`thinking-selector-${placement}`,
|
||||||
aria-label={label}
|
className,
|
||||||
value={selected}
|
]
|
||||||
disabled={disabled}
|
.filter(Boolean)
|
||||||
onChange={(event) => onChange(event.currentTarget.value)}
|
.join(" ");
|
||||||
>
|
const closeMenu = (restoreFocus = false) => {
|
||||||
{thinkingLevelOptions(model).map((option) => (
|
if (!openRef.current) return;
|
||||||
<option key={option.value} value={option.value}>
|
openRef.current = false;
|
||||||
{option.label}
|
setOpen(false);
|
||||||
</option>
|
typeaheadRef.current = { query: "", time: 0 };
|
||||||
))}
|
if (restoreFocus) {
|
||||||
</select>
|
window.cancelAnimationFrame(focusFrameRef.current ?? 0);
|
||||||
</label>
|
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 (
|
||||||
|
<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"));
|
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
|
||||||
.getByRole("combobox", { name: "Thinking" })
|
.getAllByRole("option")
|
||||||
.querySelectorAll("option"),
|
.map((option) => option.getAttribute("value")),
|
||||||
).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", () => {
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 trigger of triggers) {
|
||||||
|
await user.click(trigger);
|
||||||
|
expect(options(screen.getByRole("listbox", { name: "Thinking options" }))).toEqual(
|
||||||
|
expected,
|
||||||
);
|
);
|
||||||
for (const selector of screen.getAllByRole("combobox", {
|
await user.click(trigger);
|
||||||
name: "Thinking",
|
}
|
||||||
}) as HTMLSelectElement[])
|
|
||||||
expect(options(selector)).toEqual(expected);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user