fix(quick-pane): refresh models and window lifecycle
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user