diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index 797dd47..43746cd 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -65,8 +65,8 @@ test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"), ]); assert.match(pane, /useState/); - assert.match(pane, /provider: selectedModel\.provider/); - assert.match(pane, /modelId: selectedModel\.modelId/); + assert.match(pane, /provider: submittedModel\.provider/); + assert.match(pane, /modelId: submittedModel\.modelId/); assert.doesNotMatch(pane, /selectedModel\.split/); assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/); assert.match( @@ -76,6 +76,46 @@ test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on assert.match(catalog, /JSON\.parse\(value\)/); }); +test("Quick Pane dismiss and mutually exclusive window visibility stay native-serialized", async () => { + const [pane, nativeSource] = await Promise.all([ + readFile( + new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), + "utf8", + ), + readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"), + ]); + assert.match(pane, /invoke\("dismiss_quick_pane"\)/); + assert.doesNotMatch(pane, /getCurrentWindow\(\)\.hide\(\)/); + assert.match(nativeSource, /fn dismiss_quick_pane\(/); + assert.match(nativeSource, /dismiss_quick_pane,[\s\S]*create_session_runtime/); + assert.match( + nativeSource, + /struct WindowVisibility\(Mutex\)/, + ); + + const mainShow = nativeSource.slice( + nativeSource.indexOf("fn apply_main_window_action"), + nativeSource.indexOf("fn get_or_create_quick_pane"), + ); + const mainHideQuick = mainShow.indexOf("quick_pane.hide()"); + const mainClearQuick = mainShow.indexOf('visibility.hidden("quick-pane")'); + const showMain = mainShow.indexOf("window.show()"); + assert.ok(mainShow.indexOf('get_webview_window("quick-pane")') >= 0); + assert.ok(mainHideQuick >= 0 && mainHideQuick < showMain); + assert.ok(mainClearQuick >= 0 && mainClearQuick < showMain); + + const quickShow = nativeSource.slice( + nativeSource.indexOf("fn toggle_quick_pane"), + nativeSource.indexOf("fn requested_new_worktree_argument"), + ); + const quickHideMain = quickShow.indexOf("main.hide()"); + const quickClearMain = quickShow.indexOf('visibility.hidden("main")'); + const showQuick = quickShow.indexOf("window.show()"); + assert.ok(quickShow.indexOf('get_webview_window("main")') >= 0); + assert.ok(quickHideMain >= 0 && quickHideMain < showQuick); + assert.ok(quickClearMain >= 0 && quickClearMain < showQuick); +}); + test("Quick Pane routes extension responses through existing bridge command", async () => { const source = await readFile( new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index 50ca085..fd52f6d 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -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>>); -struct QuickPaneVisibility(Mutex); +#[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); struct WorkspaceSubscription { generation: Arc, task: Mutex>>, @@ -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::() 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::() else { - eprintln!("Quick Pane visibility state is unavailable"); + let Some(state) = app.try_state::() 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 { 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::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::() { - let mut current = state + if let Some(state) = window.app_handle().try_state::() { + 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::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] diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index a3f1d61..b1ae018 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); const snapshot = deferred(); - 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(); await user.click(screen.getByRole("button", { name: "Controls" })); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4d08172..7de5a95 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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(); const [modelCatalog, setModelCatalog] = useState([]); + const [modelCatalogError, setModelCatalogError] = useState(); + 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 && (

{quickPaneSettingsStatus}

)} + {modelCatalogError &&

{modelCatalogError}

} {quickPaneSettingsError &&

{quickPaneSettingsError}

} {selected ? ( diff --git a/ui/src/model-catalog.ts b/ui/src/model-catalog.ts index 5077a4c..ecca4a0 100644 --- a/ui/src/model-catalog.ts +++ b/ui/src/model-catalog.ts @@ -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[], diff --git a/ui/src/quick-pane/QuickPane.test.tsx b/ui/src/quick-pane/QuickPane.test.tsx index 7c3a848..ef83195 100644 --- a/ui/src/quick-pane/QuickPane.test.tsx +++ b/ui/src/quick-pane/QuickPane.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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) => void; const firstSnapshot = new Promise>( @@ -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(); }); }); diff --git a/ui/src/quick-pane/QuickPane.tsx b/ui/src/quick-pane/QuickPane.tsx index 7991003..447455b 100644 --- a/ui/src/quick-pane/QuickPane.tsx +++ b/ui/src/quick-pane/QuickPane.tsx @@ -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([]); + const [catalogStatus, setCatalogStatus] = useState< + "loading" | "ready" | "error" + >("loading"); const [selectedModel, setSelectedModel] = useState({ provider: "", modelId: "", @@ -117,6 +121,7 @@ export default function QuickPane() { const [notice, setNotice] = useState("Loading Quick Pane settings…"); const [error, setError] = useState(); const [catalogError, setCatalogError] = useState(); + const [catalogNotice, setCatalogNotice] = useState(); const [runtime, setRuntime] = useState(); const [answer, setAnswer] = useState(""); const [completedRuntimeId, setCompletedRuntimeId] = useState(); @@ -137,12 +142,12 @@ export default function QuickPane() { const completedRuntimeRef = useRef(undefined); const submissionStartingRef = useRef(false); const runStartedAtRef = useRef(undefined); + const catalogRequestRef = useRef(0); + const catalogReadyRef = useRef(false); + const selectedModelRef = useRef(selectedModel); const inputRef = useRef(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); + } }} >