fix(quick-pane): refresh models and window lifecycle

This commit is contained in:
2026-08-26 15:58:01 +02:00
parent d34099771c
commit 4ec951bc05
7 changed files with 869 additions and 130 deletions
+261 -5
View File
@@ -77,6 +77,244 @@ describe("QuickPane", () => {
mocks.onCloseRequested.mockResolvedValue(vi.fn());
});
it("renders toolbar before composer before exchange", () => {
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog")
return Promise.resolve({ models: [] });
return Promise.resolve(undefined);
});
const { container } = render(<QuickPane />);
const pane = container.querySelector(".quick-pane");
expect(
Array.from(pane?.children ?? []).map((element) => element.className),
).toEqual([
"quick-pane-toolbar",
"quick-pane-composer",
"quick-pane-exchange",
]);
});
it("replaces a removed model and submits only the returned catalog model", async () => {
const user = userEvent.setup();
let catalogCalls = 0;
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") {
catalogCalls += 1;
return Promise.resolve({
models:
catalogCalls === 1
? [
{ provider: "provider", id: "quick/model" },
{ provider: "other", id: "other/model" },
]
: [
{ provider: "provider", id: "new/model" },
{ provider: "other", id: "other/model" },
],
});
}
if (command === "create_quick_runtime")
return Promise.resolve({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
if (command === "get_session_runtime_snapshot")
return Promise.resolve({
runtime: {},
state: { data: { isStreaming: true } },
extensions: [],
});
return Promise.resolve(undefined);
});
render(<QuickPane />);
const selector = await screen.findByRole("combobox", { name: "Model" });
await waitFor(() =>
expect(
within(selector).getByRole("option", { name: "provider/quick/model" }),
).toBeInTheDocument(),
);
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(
await within(selector).findByRole("option", {
name: "provider/new/model",
}),
).toBeInTheDocument();
expect(
within(selector).queryByRole("option", {
name: "provider/quick/model",
}),
).toBeNull();
expect(selector).toHaveValue(
JSON.stringify(["provider", "new/model"]),
);
expect(
screen.getByText(
"Selected model unavailable; using provider/new/model.",
),
).toBeInTheDocument();
await user.type(screen.getByRole("textbox", { name: "Request" }), "Inspect");
await user.click(screen.getByRole("button", { name: "Send request" }));
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-1",
provider: "provider",
modelId: "new/model",
}),
);
expect(mocks.invoke).not.toHaveBeenCalledWith("set_model", {
agentId: "agent-1",
provider: "provider",
modelId: "quick/model",
});
});
it("clears selection and blocks submission for an empty catalog", async () => {
const user = userEvent.setup();
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") return Promise.resolve({ models: [] });
return Promise.resolve(undefined);
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await screen.findByText("No models are currently available.");
expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("");
await user.type(input, "Must not run");
expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled();
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_quick_runtime",
expect.anything(),
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Model catalog is not ready.",
);
});
it("blocks submission while catalog is loading and after refresh failure", async () => {
let rejectCatalog!: (reason?: unknown) => void;
const catalog = new Promise((_, reject) => {
rejectCatalog = reject;
});
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") return catalog;
return Promise.resolve(undefined);
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
fireEvent.change(input, { target: { value: "Must wait" } });
expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled();
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_quick_runtime",
expect.anything(),
);
await act(async () => rejectCatalog(new Error("offline")));
expect(
await screen.findByText("Could not load model catalog: Error: offline"),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled();
fireEvent.keyDown(input, { key: "Enter" });
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_quick_runtime",
expect.anything(),
);
});
it("fetches a fresh catalog on every pane focus", async () => {
let catalogCalls = 0;
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") {
catalogCalls += 1;
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
}
return Promise.resolve(undefined);
});
render(<QuickPane />);
await waitFor(() => expect(catalogCalls).toBe(1));
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(catalogCalls).toBe(2));
await act(async () => mocks.focusHandler?.({ payload: false }));
expect(catalogCalls).toBe(2);
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(catalogCalls).toBe(3));
});
it("keeps the submitted model when catalog refreshes during startup", async () => {
const user = userEvent.setup();
let catalogCalls = 0;
let resolveCreate:
| ((value: { runtime: { agentId: string; runtimeId: string } }) => void)
| undefined;
const createRuntime = new Promise<{
runtime: { agentId: string; runtimeId: string };
}>((resolve) => {
resolveCreate = resolve;
});
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") {
catalogCalls += 1;
return Promise.resolve({
models: [
{
provider: "provider",
id: catalogCalls === 1 ? "quick/model" : "new/model",
},
],
});
}
if (command === "create_quick_runtime") return createRuntime;
if (command === "get_session_runtime_snapshot")
return Promise.resolve({
runtime: {},
state: { data: { isStreaming: true } },
extensions: [],
});
return Promise.resolve(undefined);
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await waitFor(() => expect(catalogCalls).toBe(1));
await user.type(input, "Inspect with selected model");
await user.click(screen.getByRole("button", { name: "Send request" }));
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(catalogCalls).toBe(2));
await act(async () => {
resolveCreate?.({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
await createRuntime;
});
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-1",
provider: "provider",
modelId: "quick/model",
}),
);
});
it("clears a rejected focus reload after success without enabling handoff", async () => {
const user = userEvent.setup();
let catalogCalls = 0;
@@ -202,7 +440,7 @@ describe("QuickPane", () => {
});
});
it("mounts an empty busy live region before the first snapshot and keeps it through completion", async () => {
it("keeps pre-text activity inside the stable busy assistant region through completion", async () => {
const user = userEvent.setup();
let resolveSnapshot!: (value: ReturnType<typeof snapshot>) => void;
const firstSnapshot = new Promise<ReturnType<typeof snapshot>>(
@@ -237,9 +475,19 @@ describe("QuickPane", () => {
const assistantResponse = screen.getByRole("region", {
name: "Assistant response",
});
expect(assistantResponse).toBeEmptyDOMElement();
expect(assistantResponse).not.toBeEmptyDOMElement();
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
const preTextActivity = assistantResponse.querySelector(
".quick-pane-activity",
);
expect(preTextActivity).toBeInTheDocument();
expect(preTextActivity).toHaveTextContent(/Starting|Thinking/);
expect(
assistantResponse.parentElement?.querySelector(
":scope > .quick-pane-activity",
),
).toBeNull();
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-1",
@@ -442,7 +690,10 @@ describe("QuickPane", () => {
{ timeout: 2_000 },
),
).toBeInTheDocument();
expect(assistantResponse).toBeEmptyDOMElement();
expect(
assistantResponse.querySelector(".quick-pane-activity"),
).toBeInTheDocument();
expect(assistantResponse).not.toHaveTextContent(prefix);
expect(
screen.queryByText("Continue in full session"),
).not.toBeInTheDocument();
@@ -690,7 +941,7 @@ describe("QuickPane", () => {
);
});
it("leaves close interception native while preserving close and Escape hiding", async () => {
it("routes Close and Escape through the serialized native dismiss command", async () => {
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog")
@@ -706,6 +957,11 @@ describe("QuickPane", () => {
screen.getByRole("button", { name: "Close Quick Pane" }),
);
fireEvent.keyDown(window, { key: "Escape" });
expect(mocks.hide).toHaveBeenCalledTimes(2);
expect(
mocks.invoke.mock.calls.filter(
([command]) => command === "dismiss_quick_pane",
),
).toHaveLength(2);
expect(mocks.hide).not.toHaveBeenCalled();
});
});
+125 -70
View File
@@ -11,6 +11,7 @@ import {
modelIdentity,
modelIdentityFromValue,
modelIdentityValue,
reconcileModelIdentity,
type ModelIdentity,
} from "../model-catalog";
import "./quick-pane.css";
@@ -110,6 +111,9 @@ export default function QuickPane() {
const [submissionStarting, setSubmissionStarting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState<
"loading" | "ready" | "error"
>("loading");
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
provider: "",
modelId: "",
@@ -117,6 +121,7 @@ export default function QuickPane() {
const [notice, setNotice] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>();
const [catalogError, setCatalogError] = useState<string>();
const [catalogNotice, setCatalogNotice] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
@@ -137,12 +142,12 @@ export default function QuickPane() {
const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const catalogRequestRef = useRef(0);
const catalogReadyRef = useRef(false);
const selectedModelRef = useRef(selectedModel);
const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo(
() => catalogModels(models, [selectedModel]),
[models, selectedModel],
);
const modelOptions = useMemo(() => catalogModels(models, []), [models]);
const selectedModelValue =
selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel)
@@ -174,16 +179,45 @@ export default function QuickPane() {
? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase;
const dismissPane = () => getCurrentWindow().hide();
const dismissPane = () => invoke("dismiss_quick_pane");
const focusInput = () => inputRef.current?.focus();
const loadModelCatalog = useCallback(async () => {
try {
setModels(await getModelCatalog());
setCatalogError(undefined);
} catch (reason) {
setCatalogError(`Could not load model catalog: ${String(reason)}`);
}
}, []);
const loadModelCatalog = useCallback(
async (preferredModel?: ModelIdentity) => {
const requestId = ++catalogRequestRef.current;
catalogReadyRef.current = false;
setCatalogStatus("loading");
try {
const freshModels = await getModelCatalog();
if (requestId !== catalogRequestRef.current) return;
const preferred = preferredModel ?? selectedModelRef.current;
const reconciled = reconcileModelIdentity(freshModels, preferred);
setModels(freshModels);
setCatalogError(undefined);
catalogReadyRef.current = true;
setCatalogStatus("ready");
if (!reconciled) {
const emptyModel = { provider: "", modelId: "" };
selectedModelRef.current = emptyModel;
setSelectedModel(emptyModel);
setCatalogNotice("No models are currently available.");
return;
}
selectedModelRef.current = reconciled;
setSelectedModel(reconciled);
setCatalogNotice(
modelIdentityValue(reconciled) === modelIdentityValue(preferred)
? undefined
: `Selected model unavailable; using ${reconciled.provider}/${reconciled.modelId}.`,
);
} catch (reason) {
if (requestId !== catalogRequestRef.current) return;
catalogReadyRef.current = false;
setCatalogStatus("error");
setCatalogError(`Could not load model catalog: ${String(reason)}`);
}
},
[],
);
useEffect(() => {
let active = true;
@@ -200,12 +234,14 @@ export default function QuickPane() {
setSettings(nextSettings);
setSettingsLoaded(true);
setSelectedModel({
const configuredModel = {
provider: nextSettings.provider,
modelId: nextSettings.model,
});
};
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
setNotice("");
if (active) await loadModelCatalog();
if (active) await loadModelCatalog(configuredModel);
})();
return () => {
active = false;
@@ -244,10 +280,13 @@ export default function QuickPane() {
(event) => {
const nextSettings = { ...defaults, ...event.payload };
setSettings(nextSettings);
setSelectedModel({
const configuredModel = {
provider: nextSettings.provider,
modelId: nextSettings.model,
});
};
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
void loadModelCatalog(configuredModel);
},
);
const onKeyDown = (event: KeyboardEvent) => {
@@ -363,11 +402,14 @@ export default function QuickPane() {
async function run() {
if (submissionStartingRef.current || runActive) return;
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
return setError("Model catalog is not ready.");
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
const submittedModel = selectedModelRef.current;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
runStartedAtRef.current = Date.now();
@@ -405,8 +447,8 @@ export default function QuickPane() {
setRuntime(result.runtime);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: selectedModel.provider || settings.provider,
modelId: selectedModel.modelId || settings.model,
provider: submittedModel.provider,
modelId: submittedModel.modelId,
});
await invoke("set_thinking_level", {
agentId: result.runtime.agentId,
@@ -487,7 +529,10 @@ export default function QuickPane() {
const identity = modelIdentityFromValue(
event.currentTarget.value,
);
if (identity) setSelectedModel(identity);
if (identity) {
selectedModelRef.current = identity;
setSelectedModel(identity);
}
}}
>
<option value="" disabled>
@@ -563,6 +608,50 @@ export default function QuickPane() {
</div>
</header>
<div className="quick-pane-composer">
<textarea
className="quick-pane-request"
aria-label="Request"
autoFocus
ref={inputRef}
readOnly={runActive}
aria-busy={runActive}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void run();
}
}}
placeholder="Ask a quick question or give a quick instruction"
/>
<button
className="quick-pane-send"
type="button"
aria-label={submissionStarting ? "Starting request" : "Send request"}
title="Send (Enter)"
disabled={
runActive ||
!settingsLoaded ||
catalogStatus !== "ready" ||
!selectedModel.modelId ||
!!invalid ||
!request.trim()
}
onClick={run}
>
<span aria-hidden="true">{submissionStarting ? "•••" : "↑"}</span>
</button>
<span className="quick-pane-composer-hint">
Shift+Enter for newline
</span>
</div>
<section className="quick-pane-exchange" aria-label="Quick exchange">
{displayedRequest && (
<div className="quick-pane-user-row">
@@ -582,6 +671,16 @@ export default function QuickPane() {
aria-live={viewingHistory ? "off" : "polite"}
aria-busy={!viewingHistory && runActive}
>
{!viewingHistory && activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
<i />
<i />
</span>
<span>{activity}</span>
</div>
)}
{displayedAnswer && (
<>
<span className="message-author">Assistant</span>
@@ -595,16 +694,6 @@ export default function QuickPane() {
)}
</article>
)}
{!viewingHistory && activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
<i />
<i />
</span>
<span>{activity}</span>
</div>
)}
<span
className="quick-pane-activity-announcement sr-only"
aria-live="polite"
@@ -643,47 +732,13 @@ export default function QuickPane() {
{notice}
</p>
)}
{catalogNotice && (
<p className="quick-pane-system-notice" role="status">
{catalogNotice}
</p>
)}
<ExtensionDialog extension={extension} onRespond={respond} />
</section>
<div className="quick-pane-composer">
<textarea
className="quick-pane-request"
aria-label="Request"
autoFocus
ref={inputRef}
readOnly={runActive}
aria-busy={runActive}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void run();
}
}}
placeholder="Ask a quick question or give a quick instruction"
/>
<button
className="quick-pane-send"
type="button"
aria-label={submissionStarting ? "Starting request" : "Send request"}
title="Send (Enter)"
disabled={
runActive || !settingsLoaded || !!invalid || !request.trim()
}
onClick={run}
>
<span aria-hidden="true">{submissionStarting ? "•••" : "↑"}</span>
</button>
<span className="quick-pane-composer-hint">
Shift+Enter for newline
</span>
</div>
</main>
);
}