fix(quick-pane): refresh models and window lifecycle
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 ? (
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+125
-70
@@ -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(
|
||||||
try {
|
async (preferredModel?: ModelIdentity) => {
|
||||||
setModels(await getModelCatalog());
|
const requestId = ++catalogRequestRef.current;
|
||||||
setCatalogError(undefined);
|
catalogReadyRef.current = false;
|
||||||
} catch (reason) {
|
setCatalogStatus("loading");
|
||||||
setCatalogError(`Could not load model catalog: ${String(reason)}`);
|
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(() => {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user