fix(quick-pane): refresh models and window lifecycle
This commit is contained in:
+164
-5
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
|
||||
@@ -149,10 +149,10 @@ describe("App command progress and errors", () => {
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
|
||||
expect(quickModel).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -185,13 +185,172 @@ describe("App command progress and errors", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes available models whenever Settings opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
let catalogCalls = 0;
|
||||
const loaded: UiState = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
return Promise.resolve({
|
||||
models: [
|
||||
{
|
||||
provider: "openai",
|
||||
id: catalogCalls === 1 ? "old-model" : "new-model",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
const quickModel = await screen.findByRole("combobox", {
|
||||
name: /Quick model/,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/old-model" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
const refreshedQuickModel = screen.getByRole("combobox", {
|
||||
name: /Quick model/,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(refreshedQuickModel).getByRole("option", {
|
||||
name: "openai/new-model",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(refreshedQuickModel).queryByRole("option", {
|
||||
name: "openai/old-model",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(refreshedQuickModel).toHaveValue(
|
||||
modelIdentityValue({ provider: "openai", modelId: "new-model" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an older catalog response that resolves after a newer refresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstCatalog = deferred<{ models: { provider: string; id: string }[] }>();
|
||||
const secondCatalog = deferred<{ models: { provider: string; id: string }[] }>();
|
||||
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 catalogCalls === 1 ? firstCatalog.promise : secondCatalog.promise;
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
|
||||
await act(async () =>
|
||||
secondCatalog.resolve({
|
||||
models: [{ provider: "openai", id: "new-model" }],
|
||||
}),
|
||||
);
|
||||
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/new-model" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
firstCatalog.resolve({
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/new-model" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(quickModel).queryByRole("option", { name: "openai/stale-model" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clears catalog refresh error after a successful retry", 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;
|
||||
if (catalogCalls === 1)
|
||||
return Promise.reject(new Error("catalog offline"));
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "openai", id: "recovered-model" }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not load model catalog: Error: catalog offline",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Could not load model catalog: Error: catalog offline",
|
||||
),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("combobox", { name: /Quick model/ })).getByRole(
|
||||
"option",
|
||||
{ name: "openai/recovered-model" },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||
const user = userEvent.setup();
|
||||
const restart = deferred<unknown>();
|
||||
const snapshot = deferred<boolean>();
|
||||
mocks.invoke.mockImplementation((command: string) =>
|
||||
command === "restart" ? restart.promise : Promise.resolve(undefined),
|
||||
);
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "restart") return restart.promise;
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({ models: [] });
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
|
||||
+52
-20
@@ -17,6 +17,7 @@ import {
|
||||
modelIdentity,
|
||||
modelIdentityFromValue,
|
||||
modelIdentityValue,
|
||||
reconcileModelIdentity,
|
||||
} from "./model-catalog";
|
||||
import type {
|
||||
DirectorySession,
|
||||
@@ -169,6 +170,51 @@ export default function App() {
|
||||
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
|
||||
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
|
||||
const [modelCatalog, setModelCatalog] = useState<Model[]>([]);
|
||||
const [modelCatalogError, setModelCatalogError] = useState<string>();
|
||||
const modelCatalogRequest = useRef(0);
|
||||
const uiStateLoaded = uiState !== undefined;
|
||||
const refreshModelCatalog = useCallback(async () => {
|
||||
const requestId = ++modelCatalogRequest.current;
|
||||
try {
|
||||
const freshModels = await getModelCatalog();
|
||||
if (requestId !== modelCatalogRequest.current) return;
|
||||
setModelCatalog(freshModels);
|
||||
setModelCatalogError(undefined);
|
||||
setQuickPaneSettings((current) => {
|
||||
const quickModel = reconcileModelIdentity(freshModels, {
|
||||
provider: current.provider,
|
||||
modelId: current.model,
|
||||
});
|
||||
const followUpModel = reconcileModelIdentity(freshModels, {
|
||||
provider: current.followUpProvider,
|
||||
modelId: current.followUpModel,
|
||||
});
|
||||
const next = {
|
||||
...current,
|
||||
provider: quickModel?.provider ?? "",
|
||||
model: quickModel?.modelId ?? "",
|
||||
followUpProvider: followUpModel?.provider ?? "",
|
||||
followUpModel: followUpModel?.modelId ?? "",
|
||||
};
|
||||
if (
|
||||
next.provider === current.provider &&
|
||||
next.model === current.model &&
|
||||
next.followUpProvider === current.followUpProvider &&
|
||||
next.followUpModel === current.followUpModel
|
||||
)
|
||||
return current;
|
||||
setQuickPaneSettingsStatus(
|
||||
freshModels.length > 0
|
||||
? "Unavailable model selection replaced from current catalog."
|
||||
: "No models are currently available.",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId !== modelCatalogRequest.current) return;
|
||||
setModelCatalogError(`Could not load model catalog: ${String(error)}`);
|
||||
}
|
||||
}, []);
|
||||
const setRuntimeStatus = (runtimeId: string, detail: string) =>
|
||||
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
|
||||
const clearRuntimeStatus = (runtimeId: string) =>
|
||||
@@ -228,21 +274,15 @@ export default function App() {
|
||||
`Could not load Quick Pane settings: ${String(error)}`,
|
||||
);
|
||||
});
|
||||
void getModelCatalog()
|
||||
.then((models) => {
|
||||
if (active) setModelCatalog(models);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active)
|
||||
setQuickPaneSettingsError(
|
||||
`Could not load model catalog: ${String(error)}`,
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings" && uiStateLoaded) void refreshModelCatalog();
|
||||
}, [refreshModelCatalog, uiStateLoaded, view]);
|
||||
|
||||
const selectedRuntimeId = selected?.summary.runtimeId;
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId) return;
|
||||
@@ -709,16 +749,7 @@ export default function App() {
|
||||
.map((entry) => entry.runtimeId as string),
|
||||
);
|
||||
const sameDirectoryCollision = directory && directory.openCount > 1;
|
||||
const quickPaneModelOptions = catalogModels(modelCatalog, [
|
||||
{
|
||||
provider: quickPaneSettings.provider,
|
||||
modelId: quickPaneSettings.model,
|
||||
},
|
||||
{
|
||||
provider: quickPaneSettings.followUpProvider,
|
||||
modelId: quickPaneSettings.followUpModel,
|
||||
},
|
||||
]);
|
||||
const quickPaneModelOptions = catalogModels(modelCatalog, []);
|
||||
const quickModelValue =
|
||||
quickPaneSettings.provider && quickPaneSettings.model
|
||||
? modelIdentityValue({
|
||||
@@ -1027,6 +1058,7 @@ export default function App() {
|
||||
{quickPaneSettingsStatus && (
|
||||
<p role="status">{quickPaneSettingsStatus}</p>
|
||||
)}
|
||||
{modelCatalogError && <p role="alert">{modelCatalogError}</p>}
|
||||
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
|
||||
</form>
|
||||
{selected ? (
|
||||
|
||||
@@ -29,6 +29,23 @@ export function modelIdentityFromValue(
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileModelIdentity(
|
||||
models: Model[],
|
||||
preferred: ModelIdentity,
|
||||
): ModelIdentity | undefined {
|
||||
const identities = models
|
||||
.map(modelIdentity)
|
||||
.filter((identity): identity is ModelIdentity => !!identity);
|
||||
const preferredValue = modelIdentityValue(preferred);
|
||||
return (
|
||||
identities.find(
|
||||
(identity) => modelIdentityValue(identity) === preferredValue,
|
||||
) ??
|
||||
identities.find((identity) => identity.provider === preferred.provider) ??
|
||||
identities[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function catalogModels(
|
||||
models: Model[],
|
||||
configured: ModelIdentity[],
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user