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
+42 -2
View File
@@ -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"), readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
]); ]);
assert.match(pane, /useState<ModelIdentity>/); assert.match(pane, /useState<ModelIdentity>/);
assert.match(pane, /provider: selectedModel\.provider/); assert.match(pane, /provider: submittedModel\.provider/);
assert.match(pane, /modelId: selectedModel\.modelId/); assert.match(pane, /modelId: submittedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/); assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/); assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/);
assert.match( 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\)/); 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<ManagedWindowVisibility>\)/,
);
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 () => { test("Quick Pane routes extension responses through existing bridge command", async () => {
const source = await readFile( const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
+208 -28
View File
@@ -2,14 +2,45 @@ mod bridge;
mod ui_state; mod ui_state;
use serde_json::{json, Value}; 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::{ use tauri::{
async_runtime::JoinHandle, AppHandle, Emitter, Manager, State, WebviewWindow, async_runtime::JoinHandle, AppHandle, Emitter, Manager, State, WebviewWindow,
WebviewWindowBuilder, WindowEvent, WebviewWindowBuilder, WindowEvent,
}; };
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>); 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 { struct WorkspaceSubscription {
generation: Arc<bridge::SubscriptionGeneration>, generation: Arc<bridge::SubscriptionGeneration>,
task: Mutex<Option<JoinHandle<()>>>, task: Mutex<Option<JoinHandle<()>>>,
@@ -62,7 +93,67 @@ fn quick_pane_action(is_visible: bool) -> WindowAction {
} }
fn should_hide_close_request(label: &str) -> bool { 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> { fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
@@ -91,29 +182,33 @@ fn toggle_quick_pane(app: &AppHandle) {
return; return;
} }
}; };
let Some(state) = app.try_state::<QuickPaneVisibility>() else { let Some(state) = app.try_state::<WindowVisibility>() else {
eprintln!("Quick Pane visibility state is unavailable"); eprintln!("Window visibility state is unavailable");
return; return;
}; };
let mut current = state let mut visibility = state
.0 .0
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
if created { if created {
*current = false; visibility.quick_pane = false;
} }
match quick_pane_action(*current) { match quick_pane_action(visibility.quick_pane) {
WindowAction::Hide => { WindowAction::Hide => {
if window.hide().is_ok() { if window.hide().is_ok() {
*current = false; visibility.hidden("quick-pane");
} }
} }
WindowAction::ShowAndFocus => { WindowAction::ShowAndFocus => {
if let Some(main) = app.get_webview_window("main") { 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() { if window.show().is_ok() {
*current = true; visibility.quick_pane_shown();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
} }
@@ -188,6 +283,23 @@ async fn get_model_catalog() -> Result<Value, String> {
bridge::get_model_catalog(&socket_path()?).await 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] #[tauri::command]
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> { async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
bridge::create_session_runtime(&socket_path()?, &worktree_path).await bridge::create_session_runtime(&socket_path()?, &worktree_path).await
@@ -443,7 +555,10 @@ pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(LegacySubscription(Mutex::new(None))) .manage(LegacySubscription(Mutex::new(None)))
.manage(QuickPaneVisibility(Mutex::new(false))) .manage(WindowVisibility(Mutex::new(ManagedWindowVisibility {
main: true,
quick_pane: false,
})))
.manage(WorkspaceSubscription { .manage(WorkspaceSubscription {
generation: Arc::new(bridge::SubscriptionGeneration::default()), generation: Arc::new(bridge::SubscriptionGeneration::default()),
task: Mutex::new(None), task: Mutex::new(None),
@@ -454,29 +569,19 @@ pub fn run() {
return; return;
} }
launch_new_session(app.clone(), &args); launch_new_session(app.clone(), &args);
if let Some(window) = app.get_webview_window("main") { apply_main_window_action(app, &args);
match window_action(&args, window.is_visible().unwrap_or(false)) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
let _ = window.show();
let _ = window.set_focus();
}
}
}
})) }))
.on_window_event(|window, event| { .on_window_event(|window, event| {
if should_hide_close_request(window.label()) { if should_hide_close_request(window.label()) {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
if let Some(state) = window.app_handle().try_state::<QuickPaneVisibility>() { if let Some(state) = window.app_handle().try_state::<WindowVisibility>() {
let mut current = state let mut visibility = state
.0 .0
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
if window.hide().is_ok() { if window.hide().is_ok() {
*current = false; visibility.hidden(window.label());
} }
} else { } else {
let _ = window.hide(); let _ = window.hide();
@@ -496,6 +601,7 @@ pub fn run() {
get_workspace, get_workspace,
get_workspace_summary, get_workspace_summary,
get_model_catalog, get_model_catalog,
dismiss_quick_pane,
create_session_runtime, create_session_runtime,
create_quick_runtime, create_quick_runtime,
open_session_runtime, open_session_runtime,
@@ -570,9 +676,83 @@ mod tests {
} }
#[test] #[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("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] #[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 userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types"; import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
@@ -149,10 +149,10 @@ describe("App command progress and errors", () => {
return Promise.resolve(undefined); return Promise.resolve(undefined);
}); });
render(<App />); render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" }));
await waitFor(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"), expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"),
); );
await user.click(screen.getByRole("button", { name: "Controls" }));
const quickModel = screen.getByRole("combobox", { name: /Quick model/ }); const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
expect(quickModel).toBeInTheDocument(); expect(quickModel).toBeInTheDocument();
expect( 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 () => { it("shows restart progress until the replacement snapshot is restored", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const restart = deferred<unknown>(); const restart = deferred<unknown>();
const snapshot = deferred<boolean>(); const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) => mocks.invoke.mockImplementation((command: string) => {
command === "restart" ? restart.promise : Promise.resolve(undefined), 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); mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />); render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" })); await user.click(screen.getByRole("button", { name: "Controls" }));
+52 -20
View File
@@ -17,6 +17,7 @@ import {
modelIdentity, modelIdentity,
modelIdentityFromValue, modelIdentityFromValue,
modelIdentityValue, modelIdentityValue,
reconcileModelIdentity,
} from "./model-catalog"; } from "./model-catalog";
import type { import type {
DirectorySession, DirectorySession,
@@ -169,6 +170,51 @@ export default function App() {
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState(""); const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>(); const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
const [modelCatalog, setModelCatalog] = useState<Model[]>([]); 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) => const setRuntimeStatus = (runtimeId: string, detail: string) =>
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail })); setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
const clearRuntimeStatus = (runtimeId: string) => const clearRuntimeStatus = (runtimeId: string) =>
@@ -228,21 +274,15 @@ export default function App() {
`Could not load Quick Pane settings: ${String(error)}`, `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 () => { return () => {
active = false; active = false;
}; };
}, []); }, []);
useEffect(() => {
if (view === "settings" && uiStateLoaded) void refreshModelCatalog();
}, [refreshModelCatalog, uiStateLoaded, view]);
const selectedRuntimeId = selected?.summary.runtimeId; const selectedRuntimeId = selected?.summary.runtimeId;
useEffect(() => { useEffect(() => {
if (!selectedRuntimeId) return; if (!selectedRuntimeId) return;
@@ -709,16 +749,7 @@ export default function App() {
.map((entry) => entry.runtimeId as string), .map((entry) => entry.runtimeId as string),
); );
const sameDirectoryCollision = directory && directory.openCount > 1; const sameDirectoryCollision = directory && directory.openCount > 1;
const quickPaneModelOptions = catalogModels(modelCatalog, [ const quickPaneModelOptions = catalogModels(modelCatalog, []);
{
provider: quickPaneSettings.provider,
modelId: quickPaneSettings.model,
},
{
provider: quickPaneSettings.followUpProvider,
modelId: quickPaneSettings.followUpModel,
},
]);
const quickModelValue = const quickModelValue =
quickPaneSettings.provider && quickPaneSettings.model quickPaneSettings.provider && quickPaneSettings.model
? modelIdentityValue({ ? modelIdentityValue({
@@ -1027,6 +1058,7 @@ export default function App() {
{quickPaneSettingsStatus && ( {quickPaneSettingsStatus && (
<p role="status">{quickPaneSettingsStatus}</p> <p role="status">{quickPaneSettingsStatus}</p>
)} )}
{modelCatalogError && <p role="alert">{modelCatalogError}</p>}
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>} {quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
</form> </form>
{selected ? ( {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( export function catalogModels(
models: Model[], models: Model[],
configured: ModelIdentity[], configured: ModelIdentity[],
+261 -5
View File
@@ -77,6 +77,244 @@ describe("QuickPane", () => {
mocks.onCloseRequested.mockResolvedValue(vi.fn()); 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 () => { it("clears a rejected focus reload after success without enabling handoff", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let catalogCalls = 0; 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(); const user = userEvent.setup();
let resolveSnapshot!: (value: ReturnType<typeof snapshot>) => void; let resolveSnapshot!: (value: ReturnType<typeof snapshot>) => void;
const firstSnapshot = new Promise<ReturnType<typeof snapshot>>( const firstSnapshot = new Promise<ReturnType<typeof snapshot>>(
@@ -237,9 +475,19 @@ describe("QuickPane", () => {
const assistantResponse = screen.getByRole("region", { const assistantResponse = screen.getByRole("region", {
name: "Assistant response", name: "Assistant response",
}); });
expect(assistantResponse).toBeEmptyDOMElement(); expect(assistantResponse).not.toBeEmptyDOMElement();
expect(assistantResponse).toHaveAttribute("aria-live", "polite"); expect(assistantResponse).toHaveAttribute("aria-live", "polite");
expect(assistantResponse).toHaveAttribute("aria-busy", "true"); 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(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", { expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-1", agentId: "agent-1",
@@ -442,7 +690,10 @@ describe("QuickPane", () => {
{ timeout: 2_000 }, { timeout: 2_000 },
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(assistantResponse).toBeEmptyDOMElement(); expect(
assistantResponse.querySelector(".quick-pane-activity"),
).toBeInTheDocument();
expect(assistantResponse).not.toHaveTextContent(prefix);
expect( expect(
screen.queryByText("Continue in full session"), screen.queryByText("Continue in full session"),
).not.toBeInTheDocument(); ).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) => { mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState); if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") if (command === "get_model_catalog")
@@ -706,6 +957,11 @@ describe("QuickPane", () => {
screen.getByRole("button", { name: "Close Quick Pane" }), screen.getByRole("button", { name: "Close Quick Pane" }),
); );
fireEvent.keyDown(window, { key: "Escape" }); 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();
}); });
}); });
+120 -65
View File
@@ -11,6 +11,7 @@ import {
modelIdentity, modelIdentity,
modelIdentityFromValue, modelIdentityFromValue,
modelIdentityValue, modelIdentityValue,
reconcileModelIdentity,
type ModelIdentity, type ModelIdentity,
} from "../model-catalog"; } from "../model-catalog";
import "./quick-pane.css"; import "./quick-pane.css";
@@ -110,6 +111,9 @@ export default function QuickPane() {
const [submissionStarting, setSubmissionStarting] = useState(false); const [submissionStarting, setSubmissionStarting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium"); const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]); const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState<
"loading" | "ready" | "error"
>("loading");
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({ const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
provider: "", provider: "",
modelId: "", modelId: "",
@@ -117,6 +121,7 @@ export default function QuickPane() {
const [notice, setNotice] = useState("Loading Quick Pane settings…"); const [notice, setNotice] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [catalogError, setCatalogError] = useState<string>(); const [catalogError, setCatalogError] = useState<string>();
const [catalogNotice, setCatalogNotice] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>(); const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>(); const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
@@ -137,12 +142,12 @@ export default function QuickPane() {
const completedRuntimeRef = useRef<string | undefined>(undefined); const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false); const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined); const runStartedAtRef = useRef<number | undefined>(undefined);
const catalogRequestRef = useRef(0);
const catalogReadyRef = useRef(false);
const selectedModelRef = useRef(selectedModel);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]); const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo( const modelOptions = useMemo(() => catalogModels(models, []), [models]);
() => catalogModels(models, [selectedModel]),
[models, selectedModel],
);
const selectedModelValue = const selectedModelValue =
selectedModel.provider && selectedModel.modelId selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel) ? modelIdentityValue(selectedModel)
@@ -174,16 +179,45 @@ export default function QuickPane() {
? `${activityPhase} · ${elapsedSeconds}s` ? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase; : activityPhase;
const dismissPane = () => getCurrentWindow().hide(); const dismissPane = () => invoke("dismiss_quick_pane");
const focusInput = () => inputRef.current?.focus(); const focusInput = () => inputRef.current?.focus();
const loadModelCatalog = useCallback(async () => { const loadModelCatalog = useCallback(
async (preferredModel?: ModelIdentity) => {
const requestId = ++catalogRequestRef.current;
catalogReadyRef.current = false;
setCatalogStatus("loading");
try { try {
setModels(await getModelCatalog()); const freshModels = await getModelCatalog();
if (requestId !== catalogRequestRef.current) return;
const preferred = preferredModel ?? selectedModelRef.current;
const reconciled = reconcileModelIdentity(freshModels, preferred);
setModels(freshModels);
setCatalogError(undefined); 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) { } catch (reason) {
if (requestId !== catalogRequestRef.current) return;
catalogReadyRef.current = false;
setCatalogStatus("error");
setCatalogError(`Could not load model catalog: ${String(reason)}`); setCatalogError(`Could not load model catalog: ${String(reason)}`);
} }
}, []); },
[],
);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -200,12 +234,14 @@ export default function QuickPane() {
setSettings(nextSettings); setSettings(nextSettings);
setSettingsLoaded(true); setSettingsLoaded(true);
setSelectedModel({ const configuredModel = {
provider: nextSettings.provider, provider: nextSettings.provider,
modelId: nextSettings.model, modelId: nextSettings.model,
}); };
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
setNotice(""); setNotice("");
if (active) await loadModelCatalog(); if (active) await loadModelCatalog(configuredModel);
})(); })();
return () => { return () => {
active = false; active = false;
@@ -244,10 +280,13 @@ export default function QuickPane() {
(event) => { (event) => {
const nextSettings = { ...defaults, ...event.payload }; const nextSettings = { ...defaults, ...event.payload };
setSettings(nextSettings); setSettings(nextSettings);
setSelectedModel({ const configuredModel = {
provider: nextSettings.provider, provider: nextSettings.provider,
modelId: nextSettings.model, modelId: nextSettings.model,
}); };
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
void loadModelCatalog(configuredModel);
}, },
); );
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -363,11 +402,14 @@ export default function QuickPane() {
async function run() { async function run() {
if (submissionStartingRef.current || runActive) return; if (submissionStartingRef.current || runActive) return;
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
return setError("Model catalog is not ready.");
if (invalid || !request.trim()) if (invalid || !request.trim())
return setError(invalid ?? "Enter a request."); return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true; submissionStartingRef.current = true;
setSubmissionStarting(true); setSubmissionStarting(true);
const submittedRequest = request; const submittedRequest = request;
const submittedModel = selectedModelRef.current;
setCurrentRequest(submittedRequest); setCurrentRequest(submittedRequest);
setSelectedHistoryId(""); setSelectedHistoryId("");
runStartedAtRef.current = Date.now(); runStartedAtRef.current = Date.now();
@@ -405,8 +447,8 @@ export default function QuickPane() {
setRuntime(result.runtime); setRuntime(result.runtime);
await invoke("set_model", { await invoke("set_model", {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
provider: selectedModel.provider || settings.provider, provider: submittedModel.provider,
modelId: selectedModel.modelId || settings.model, modelId: submittedModel.modelId,
}); });
await invoke("set_thinking_level", { await invoke("set_thinking_level", {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
@@ -487,7 +529,10 @@ export default function QuickPane() {
const identity = modelIdentityFromValue( const identity = modelIdentityFromValue(
event.currentTarget.value, event.currentTarget.value,
); );
if (identity) setSelectedModel(identity); if (identity) {
selectedModelRef.current = identity;
setSelectedModel(identity);
}
}} }}
> >
<option value="" disabled> <option value="" disabled>
@@ -563,6 +608,50 @@ export default function QuickPane() {
</div> </div>
</header> </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"> <section className="quick-pane-exchange" aria-label="Quick exchange">
{displayedRequest && ( {displayedRequest && (
<div className="quick-pane-user-row"> <div className="quick-pane-user-row">
@@ -582,6 +671,16 @@ export default function QuickPane() {
aria-live={viewingHistory ? "off" : "polite"} aria-live={viewingHistory ? "off" : "polite"}
aria-busy={!viewingHistory && runActive} 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 && ( {displayedAnswer && (
<> <>
<span className="message-author">Assistant</span> <span className="message-author">Assistant</span>
@@ -595,16 +694,6 @@ export default function QuickPane() {
)} )}
</article> </article>
)} )}
{!viewingHistory && activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
<i />
<i />
</span>
<span>{activity}</span>
</div>
)}
<span <span
className="quick-pane-activity-announcement sr-only" className="quick-pane-activity-announcement sr-only"
aria-live="polite" aria-live="polite"
@@ -643,47 +732,13 @@ export default function QuickPane() {
{notice} {notice}
</p> </p>
)} )}
{catalogNotice && (
<p className="quick-pane-system-notice" role="status">
{catalogNotice}
</p>
)}
<ExtensionDialog extension={extension} onRespond={respond} /> <ExtensionDialog extension={extension} onRespond={respond} />
</section> </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> </main>
); );
} }