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();
});
});