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
+208 -28
View File
@@ -2,14 +2,45 @@ mod bridge;
mod ui_state;
use serde_json::{json, Value};
use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
use std::{
env,
path::Path,
sync::{Arc, Mutex},
time::Duration,
};
use tauri::{
async_runtime::JoinHandle, AppHandle, Emitter, Manager, State, WebviewWindow,
WebviewWindowBuilder, WindowEvent,
};
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
struct QuickPaneVisibility(Mutex<bool>);
#[derive(Debug, PartialEq, Eq)]
struct ManagedWindowVisibility {
main: bool,
quick_pane: bool,
}
impl ManagedWindowVisibility {
fn main_shown(&mut self) {
self.main = true;
self.quick_pane = false;
}
fn quick_pane_shown(&mut self) {
self.main = false;
self.quick_pane = true;
}
fn hidden(&mut self, label: &str) {
if label == "main" {
self.main = false;
} else if label == "quick-pane" {
self.quick_pane = false;
}
}
}
// One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering.
struct WindowVisibility(Mutex<ManagedWindowVisibility>);
struct WorkspaceSubscription {
generation: Arc<bridge::SubscriptionGeneration>,
task: Mutex<Option<JoinHandle<()>>>,
@@ -62,7 +93,67 @@ fn quick_pane_action(is_visible: bool) -> WindowAction {
}
fn should_hide_close_request(label: &str) -> bool {
label == "quick-pane"
label == "main" || label == "quick-pane"
}
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
if let Some(window) = app.get_webview_window("main") {
return Ok((window, false));
}
let config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.ok_or_else(|| "Main window configuration is missing".to_owned())?;
let window = WebviewWindowBuilder::from_config(app, config)
.map_err(|error| error.to_string())?
.build()
.map_err(|error| error.to_string())?;
Ok((window, true))
}
fn apply_main_window_action(app: &AppHandle, args: &[String]) {
let (window, created) = match get_or_create_main(app) {
Ok(result) => result,
Err(error) => {
eprintln!("Could not create main window: {error}");
return;
}
};
let Some(state) = app.try_state::<WindowVisibility>() else {
eprintln!("Window visibility state is unavailable");
return;
};
let mut visibility = state
.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if created {
visibility.main = false;
}
match window_action(args, visibility.main) {
WindowAction::Hide => {
if window.hide().is_ok() {
visibility.hidden("main");
}
}
WindowAction::ShowAndFocus => {
if let Some(quick_pane) = app.get_webview_window("quick-pane") {
if let Err(error) = quick_pane.hide() {
eprintln!("Could not hide Quick Pane before showing main window: {error}");
return;
}
}
visibility.hidden("quick-pane");
if window.show().is_ok() {
visibility.main_shown();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}
}
fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
@@ -91,29 +182,33 @@ fn toggle_quick_pane(app: &AppHandle) {
return;
}
};
let Some(state) = app.try_state::<QuickPaneVisibility>() else {
eprintln!("Quick Pane visibility state is unavailable");
let Some(state) = app.try_state::<WindowVisibility>() else {
eprintln!("Window visibility state is unavailable");
return;
};
let mut current = state
let mut visibility = state
.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if created {
*current = false;
visibility.quick_pane = false;
}
match quick_pane_action(*current) {
match quick_pane_action(visibility.quick_pane) {
WindowAction::Hide => {
if window.hide().is_ok() {
*current = false;
visibility.hidden("quick-pane");
}
}
WindowAction::ShowAndFocus => {
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
if let Err(error) = main.hide() {
eprintln!("Could not hide main window before showing Quick Pane: {error}");
return;
}
}
visibility.hidden("main");
if window.show().is_ok() {
*current = true;
visibility.quick_pane_shown();
let _ = window.unminimize();
let _ = window.set_focus();
}
@@ -188,6 +283,23 @@ async fn get_model_catalog() -> Result<Value, String> {
bridge::get_model_catalog(&socket_path()?).await
}
#[tauri::command]
fn dismiss_quick_pane(
app: AppHandle,
visibility: State<'_, WindowVisibility>,
) -> Result<(), String> {
let quick_pane = app
.get_webview_window("quick-pane")
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
let mut visibility = visibility
.0
.lock()
.map_err(|_| "Could not update window visibility".to_owned())?;
quick_pane.hide().map_err(|error| error.to_string())?;
visibility.hidden("quick-pane");
Ok(())
}
#[tauri::command]
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
@@ -443,7 +555,10 @@ pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(LegacySubscription(Mutex::new(None)))
.manage(QuickPaneVisibility(Mutex::new(false)))
.manage(WindowVisibility(Mutex::new(ManagedWindowVisibility {
main: true,
quick_pane: false,
})))
.manage(WorkspaceSubscription {
generation: Arc::new(bridge::SubscriptionGeneration::default()),
task: Mutex::new(None),
@@ -454,29 +569,19 @@ pub fn run() {
return;
}
launch_new_session(app.clone(), &args);
if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
let _ = window.show();
let _ = window.set_focus();
}
}
}
apply_main_window_action(app, &args);
}))
.on_window_event(|window, event| {
if should_hide_close_request(window.label()) {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
if let Some(state) = window.app_handle().try_state::<QuickPaneVisibility>() {
let mut current = state
if let Some(state) = window.app_handle().try_state::<WindowVisibility>() {
let mut visibility = state
.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if window.hide().is_ok() {
*current = false;
visibility.hidden(window.label());
}
} else {
let _ = window.hide();
@@ -496,6 +601,7 @@ pub fn run() {
get_workspace,
get_workspace_summary,
get_model_catalog,
dismiss_quick_pane,
create_session_runtime,
create_quick_runtime,
open_session_runtime,
@@ -570,9 +676,83 @@ mod tests {
}
#[test]
fn only_quick_pane_native_close_requests_are_hidden() {
fn native_close_requests_hide_managed_windows() {
assert!(should_hide_close_request("main"));
assert!(should_hide_close_request("quick-pane"));
assert!(!should_hide_close_request("main"));
assert!(!should_hide_close_request("other"));
}
#[test]
fn showing_main_clears_quick_pane_visibility() {
let mut visibility = ManagedWindowVisibility {
main: false,
quick_pane: true,
};
visibility.main_shown();
assert_eq!(
visibility,
ManagedWindowVisibility {
main: true,
quick_pane: false,
}
);
}
#[test]
fn showing_quick_pane_clears_main_visibility() {
let mut visibility = ManagedWindowVisibility {
main: true,
quick_pane: false,
};
visibility.quick_pane_shown();
assert_eq!(
visibility,
ManagedWindowVisibility {
main: false,
quick_pane: true,
}
);
}
#[test]
fn dismissed_quick_pane_reopens_with_one_toggle() {
let mut visibility = ManagedWindowVisibility {
main: false,
quick_pane: true,
};
visibility.hidden("quick-pane");
assert_eq!(
quick_pane_action(visibility.quick_pane),
WindowAction::ShowAndFocus
);
visibility.quick_pane_shown();
assert!(visibility.quick_pane);
assert!(!visibility.main);
}
#[test]
fn main_recreation_uses_the_complete_configured_window() {
let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
let main = context
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.unwrap();
assert_eq!(main.width, 1100.0);
assert_eq!(main.height, 840.0);
assert_eq!(main.url.to_string(), "index.html");
assert!(main.visible);
assert!(main.transparent);
assert!(main.always_on_top);
assert!(main.skip_taskbar);
}
#[test]
+164 -5
View File
@@ -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
View File
@@ -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 ? (
+17
View File
@@ -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[],
+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>
);
}