fix(quick-pane): preserve lifecycle and answer history
This commit is contained in:
@@ -67,7 +67,8 @@ function normalizePiEvent(event) {
|
||||
case "extension_ui_request":
|
||||
return { type: "extension_ui_request", data: { event } };
|
||||
case "extension_error":
|
||||
// Extension errors are diagnostics; the Pi process can still be healthy.
|
||||
case "thinking_level_changed":
|
||||
// Informational Pi notifications do not change runtime health.
|
||||
return { type: "transcript", data: { event } };
|
||||
default:
|
||||
return undefined;
|
||||
@@ -209,6 +210,7 @@ export function startPiRpcAdapter({
|
||||
"unsupported_event",
|
||||
`Pi RPC emitted unsupported event: ${event.type}`,
|
||||
),
|
||||
{ terminal: false },
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +231,6 @@ export function startPiRpcAdapter({
|
||||
"frame_too_large",
|
||||
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
|
||||
),
|
||||
{ terminal: false },
|
||||
);
|
||||
const consumeStdout = (text) => {
|
||||
let remaining = text;
|
||||
@@ -303,10 +304,7 @@ export function startPiRpcAdapter({
|
||||
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
|
||||
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
|
||||
return Promise.reject(
|
||||
new PiRpcError(
|
||||
"invalid_command",
|
||||
"Pi RPC command must include a type",
|
||||
),
|
||||
new PiRpcError("invalid_command", "Pi RPC command must include a type"),
|
||||
);
|
||||
}
|
||||
if (Object.hasOwn(commandInput, "id")) {
|
||||
@@ -382,10 +380,7 @@ export function startPiRpcAdapter({
|
||||
if (closed) return exited;
|
||||
intentionalStop = true;
|
||||
rejectPending(
|
||||
new PiRpcError(
|
||||
"child_stopped",
|
||||
"Pi RPC child was stopped by the bridge",
|
||||
),
|
||||
new PiRpcError("child_stopped", "Pi RPC child was stopped by the bridge"),
|
||||
);
|
||||
child.kill("SIGTERM");
|
||||
return exited;
|
||||
|
||||
@@ -162,7 +162,7 @@ test("sends abort without waiting behind a blocked prompt", async () => {
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => {
|
||||
test("keeps a runtime healthy after an unknown non-terminal Pi notification", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
@@ -173,7 +173,10 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
|
||||
await registry.start();
|
||||
|
||||
fixture.calls[0].options.onError(
|
||||
{ code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" },
|
||||
{
|
||||
code: "unsupported_event",
|
||||
message: "Pi RPC emitted unsupported event: future_pi_notification",
|
||||
},
|
||||
{ terminal: false },
|
||||
);
|
||||
|
||||
@@ -184,6 +187,31 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("keeps explicit transport failures terminal", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
|
||||
fixture.calls[0].options.onError({
|
||||
code: "invalid_json",
|
||||
message: "Pi RPC emitted invalid JSON",
|
||||
});
|
||||
|
||||
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
||||
assert.equal(runtime.state, "error");
|
||||
assert.equal(runtime.attention, true);
|
||||
assert.deepEqual(runtime.error, {
|
||||
code: "invalid_json",
|
||||
message: "Pi RPC emitted invalid JSON",
|
||||
});
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
|
||||
@@ -115,10 +115,7 @@ test("allows a per-command timeout override without serializing it to Pi", async
|
||||
type: "get_state",
|
||||
id: initialCommand.id,
|
||||
});
|
||||
await assert.rejects(
|
||||
adapter.send({ type: "get_session_stats" }),
|
||||
/timed out/,
|
||||
);
|
||||
await assert.rejects(adapter.send({ type: "get_session_stats" }), /timed out/);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
@@ -208,6 +205,49 @@ test("forwards extension responses without replacing Pi's request ID", async ()
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("normalizes thinking changes and treats every unknown Pi notification as non-terminal", async () => {
|
||||
const fixture = createFixture();
|
||||
const events = [];
|
||||
const errors = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
onEvent: (event) => events.push(event),
|
||||
onError: (error, metadata) => errors.push({ error, metadata }),
|
||||
});
|
||||
const thinkingChange = {
|
||||
type: "thinking_level_changed",
|
||||
level: "high",
|
||||
};
|
||||
fixture.child.stdout.write(`${JSON.stringify(thinkingChange)}\n`);
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "future_pi_notification", detail: "new" })}\n`,
|
||||
);
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "fatal_pi_notification", terminal: true })}\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
seq: 1,
|
||||
type: "transcript",
|
||||
data: { event: thinkingChange },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
errors.map(({ error, metadata }) => ({
|
||||
code: error.code,
|
||||
terminal: metadata.terminal,
|
||||
})),
|
||||
[
|
||||
{ code: "unsupported_event", terminal: false },
|
||||
{ code: "unsupported_event", terminal: false },
|
||||
],
|
||||
);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("discards a fragmented oversized frame before processing the next frame", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
@@ -217,7 +257,7 @@ test("discards a fragmented oversized frame before processing the next frame", a
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
maxFrameBytes: 32,
|
||||
onError: (error) => errors.push(error),
|
||||
onError: (error, metadata) => errors.push({ error, metadata }),
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
const oversized = JSON.stringify({
|
||||
@@ -230,8 +270,11 @@ test("discards a fragmented oversized frame before processing the next frame", a
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
errors.map((error) => error.code),
|
||||
["frame_too_large"],
|
||||
errors.map(({ error, metadata }) => ({
|
||||
code: error.code,
|
||||
terminal: metadata?.terminal !== false,
|
||||
})),
|
||||
[{ code: "frame_too_large", terminal: true }],
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async ()
|
||||
);
|
||||
assert.match(source, /get_session_runtime_snapshot/);
|
||||
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
|
||||
assert.match(source, /\{answer && <pre>\{answer\}<\/pre>\}/);
|
||||
assert.match(source, /\{displayedAnswer && <pre>\{displayedAnswer\}<\/pre>\}/);
|
||||
assert.match(source, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/);
|
||||
assert.match(source, /\{canContinue && \(/);
|
||||
});
|
||||
@@ -40,7 +40,11 @@ test("main Settings uses shared catalog selectors with provider derived from mod
|
||||
assert.match(pane, /set_thinking_level/);
|
||||
assert.doesNotMatch(pane, /showSettings/);
|
||||
assert.doesNotMatch(pane, /Save Quick Pane settings/);
|
||||
assert.doesNotMatch(pane, /setRequest\(""\)/);
|
||||
assert.match(pane, /isStreaming === false/);
|
||||
assert.match(pane, /current === submitted\.request \? "" : current/);
|
||||
assert.match(pane, /\.slice\(0, 5\)/);
|
||||
assert.match(pane, /aria-label="Answer history"/);
|
||||
assert.doesNotMatch(pane, /localStorage|sessionStorage|save_ui_state/);
|
||||
assert.match(persistence, /quickPane: current\.quickPane/);
|
||||
});
|
||||
|
||||
@@ -75,7 +79,7 @@ test("Quick Pane routes extension responses through existing bridge command", as
|
||||
});
|
||||
|
||||
test("Quick Pane uses native dimensions and request-first compact layout", async () => {
|
||||
const [source, stylesheet, configSource] = await Promise.all([
|
||||
const [source, stylesheet, configSource, nativeSource, capabilitySource] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -85,8 +89,11 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../ui/src-tauri/tauri.conf.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../ui/src-tauri/capabilities/default.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
const config = JSON.parse(configSource);
|
||||
const capability = JSON.parse(capabilitySource);
|
||||
const quickPane = config.app.windows.find((window) => window.label === "quick-pane");
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -97,11 +104,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
||||
},
|
||||
{ width: 640, height: 440, transparent: true },
|
||||
);
|
||||
assert.ok(capability.windows.includes("quick-pane"));
|
||||
assert.match(nativeSource, /WindowEvent::CloseRequested/);
|
||||
assert.match(nativeSource, /api\.prevent_close\(\)/);
|
||||
assert.match(nativeSource, /WebviewWindowBuilder::from_config\(app, config\)/);
|
||||
assert.match(nativeSource, /get_or_create_quick_pane/);
|
||||
assert.doesNotMatch(source, /onCloseRequested/);
|
||||
assert.match(source, /className="quick-pane-request"/);
|
||||
assert.match(source, /className="quick-pane-output"/);
|
||||
assert.match(source, /<footer className="quick-pane-controls">/);
|
||||
assert.match(source, /ref=\{inputRef\}/);
|
||||
assert.match(source, /focusInput\(\)/);
|
||||
assert.doesNotMatch(source, /if \(!event\.payload\)[\s\S]*?hide\(/);
|
||||
assert.match(
|
||||
source,
|
||||
/event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
|
||||
|
||||
+66
-3
@@ -3,7 +3,10 @@ mod ui_state;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
|
||||
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
use tauri::{
|
||||
async_runtime::JoinHandle, AppHandle, Emitter, Manager, State, WebviewWindow,
|
||||
WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
|
||||
struct WorkspaceSubscription {
|
||||
@@ -57,8 +60,35 @@ fn quick_pane_action(is_visible: bool) -> WindowAction {
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_quick_pane(app: &AppHandle) {
|
||||
fn should_hide_close_request(label: &str) -> bool {
|
||||
label == "quick-pane"
|
||||
}
|
||||
|
||||
fn get_or_create_quick_pane(app: &AppHandle) -> Result<WebviewWindow, String> {
|
||||
if let Some(window) = app.get_webview_window("quick-pane") {
|
||||
return Ok(window);
|
||||
}
|
||||
let config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "quick-pane")
|
||||
.ok_or_else(|| "Quick Pane window configuration is missing".to_owned())?;
|
||||
WebviewWindowBuilder::from_config(app, config)
|
||||
.map_err(|error| error.to_string())?
|
||||
.build()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn toggle_quick_pane(app: &AppHandle) {
|
||||
let window = match get_or_create_quick_pane(app) {
|
||||
Ok(window) => window,
|
||||
Err(error) => {
|
||||
eprintln!("Could not create Quick Pane window: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match quick_pane_action(window.is_visible().unwrap_or(false)) {
|
||||
WindowAction::Hide => {
|
||||
let _ = window.hide();
|
||||
@@ -72,7 +102,6 @@ fn toggle_quick_pane(app: &AppHandle) {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
|
||||
@@ -419,6 +448,14 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}))
|
||||
.on_window_event(|window, event| {
|
||||
if should_hide_close_request(window.label()) {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.setup(move |app| {
|
||||
if requested_quick_pane(&launch_args) {
|
||||
toggle_quick_pane(app.handle());
|
||||
@@ -504,6 +541,32 @@ mod tests {
|
||||
assert_eq!(quick_pane_action(true), WindowAction::Hide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_quick_pane_native_close_requests_are_hidden() {
|
||||
assert!(should_hide_close_request("quick-pane"));
|
||||
assert!(!should_hide_close_request("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_pane_recreation_uses_the_complete_configured_window() {
|
||||
let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
|
||||
let quick_pane = context
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "quick-pane")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(quick_pane.width, 640.0);
|
||||
assert_eq!(quick_pane.height, 440.0);
|
||||
assert_eq!(quick_pane.url.to_string(), "index.html");
|
||||
assert!(!quick_pane.visible);
|
||||
assert!(quick_pane.transparent);
|
||||
assert!(quick_pane.always_on_top);
|
||||
assert!(quick_pane.skip_taskbar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
||||
let toggle = vec!["--toggle".to_owned()];
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QuickPaneSettings, UiState } from "../workspace/types";
|
||||
@@ -47,7 +54,18 @@ const uiState: UiState = {
|
||||
quickPane: settings,
|
||||
};
|
||||
|
||||
describe("QuickPane model catalog errors", () => {
|
||||
function snapshot(answer: string, isStreaming: boolean) {
|
||||
return {
|
||||
runtime: {},
|
||||
state: { data: { isStreaming } },
|
||||
transcript: {
|
||||
data: { messages: [{ role: "assistant", content: answer }] },
|
||||
},
|
||||
extensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("QuickPane", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.focusHandler = undefined;
|
||||
@@ -115,4 +133,175 @@ describe("QuickPane model catalog errors", () => {
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allows only one runtime creation while a submission is starting", async () => {
|
||||
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) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
return createRuntime;
|
||||
case "get_session_runtime_snapshot":
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
state: { data: { isStreaming: true } },
|
||||
extensions: [],
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
fireEvent.change(input, { target: { value: "Inspect once" } });
|
||||
const ask = screen.getByRole("button", { name: "Ask" });
|
||||
fireEvent.click(ask);
|
||||
fireEvent.click(ask);
|
||||
|
||||
expect(
|
||||
mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "create_quick_runtime",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Starting…" })).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate?.({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
await createRuntime;
|
||||
});
|
||||
});
|
||||
|
||||
it("clears only the submitted request after a completed non-streaming answer", async () => {
|
||||
const user = userEvent.setup();
|
||||
let currentSnapshot = snapshot("Partial answer", true);
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
case "get_session_runtime_snapshot":
|
||||
return Promise.resolve(currentSnapshot);
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
await user.type(input, "Inspect this");
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
|
||||
expect(await screen.findByText("Partial answer")).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Inspect this");
|
||||
|
||||
currentSnapshot = snapshot("Final answer", false);
|
||||
expect(
|
||||
await screen.findByText("Final answer", {}, { timeout: 2_000 }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
|
||||
await user.type(input, "Next draft");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
||||
expect(input).toHaveValue("Next draft");
|
||||
});
|
||||
|
||||
it("keeps five in-memory answers and history selection preserves current answer", async () => {
|
||||
const user = userEvent.setup();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime": {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: {
|
||||
agentId: `agent-${runtimeCount}`,
|
||||
runtimeId: `runtime-${runtimeCount}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "get_session_runtime_snapshot": {
|
||||
const segments = payload?.runtimeId?.split("-") ?? [];
|
||||
const id = Number(segments[segments.length - 1]);
|
||||
return Promise.resolve(snapshot(`Answer ${id}`, false));
|
||||
}
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
await user.type(input, `Request ${index}`);
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
}
|
||||
|
||||
const history = screen.getByRole("combobox", { name: "Answer history" });
|
||||
expect(within(history).getAllByRole("option")).toHaveLength(6);
|
||||
expect(
|
||||
within(history).queryByRole("option", { name: "Request 1" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(history).getByRole("option", { name: "Request 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(history, "runtime-2");
|
||||
expect(screen.getByText("Answer 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Answer 6")).toBeNull();
|
||||
await user.selectOptions(history, "");
|
||||
expect(screen.getByText("Answer 6")).toBeInTheDocument();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves close interception native while preserving close and Escape hiding", async () => {
|
||||
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 />);
|
||||
await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
expect(mocks.onCloseRequested).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mocks.hide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,12 @@ const defaults: QuickPaneSettings = {
|
||||
};
|
||||
|
||||
type Runtime = { agentId: string; runtimeId: string };
|
||||
type HistoryEntry = { id: string; request: string; answer: string };
|
||||
|
||||
function historyLabel(request: string) {
|
||||
const concise = request.replace(/\s+/g, " ").trim();
|
||||
return concise.length > 42 ? `${concise.slice(0, 39)}…` : concise;
|
||||
}
|
||||
|
||||
function settingsError(settings: QuickPaneSettings) {
|
||||
if (!settings.workspace.startsWith("/"))
|
||||
@@ -74,6 +80,7 @@ export default function QuickPane() {
|
||||
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [request, setRequest] = useState("");
|
||||
const [submissionStarting, setSubmissionStarting] = useState(false);
|
||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||||
@@ -85,9 +92,17 @@ export default function QuickPane() {
|
||||
const [catalogError, setCatalogError] = useState<string>();
|
||||
const [runtime, setRuntime] = useState<Runtime>();
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState("");
|
||||
const [escalation, setEscalation] = useState<string>();
|
||||
const [extension, setExtension] = useState<Extension>();
|
||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||
const submittedRequestRef = useRef<
|
||||
{ runtimeId: string; request: string } | undefined
|
||||
>(undefined);
|
||||
const completedRuntimeRef = useRef<string | undefined>(undefined);
|
||||
const submissionStartingRef = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const invalid = useMemo(() => settingsError(settings), [settings]);
|
||||
const modelOptions = useMemo(
|
||||
@@ -99,6 +114,10 @@ export default function QuickPane() {
|
||||
? modelIdentityValue(selectedModel)
|
||||
: "";
|
||||
const canContinue = !!escalation || (!!runtime && !!error);
|
||||
const displayedAnswer = selectedHistoryId
|
||||
? (history.find((entry) => entry.id === selectedHistoryId)?.answer ??
|
||||
answer)
|
||||
: answer;
|
||||
|
||||
const dismissPane = () => getCurrentWindow().hide();
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
@@ -170,15 +189,10 @@ export default function QuickPane() {
|
||||
void loadModelCatalog();
|
||||
}
|
||||
});
|
||||
const closeListener = paneWindow.onCloseRequested((event) => {
|
||||
event.preventDefault();
|
||||
void dismissPane();
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
void settingsListener.then((unlisten) => unlisten());
|
||||
void focusListener.then((unlisten) => unlisten());
|
||||
void closeListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [loadModelCatalog]);
|
||||
|
||||
@@ -202,8 +216,30 @@ export default function QuickPane() {
|
||||
text.replace("[ESCALATE]", "").trim() ||
|
||||
"Quick agent requested a full session.",
|
||||
);
|
||||
} else if (!snapshot.state?.data?.isStreaming) {
|
||||
}
|
||||
if (
|
||||
snapshot.state?.data?.isStreaming === false &&
|
||||
completedRuntimeRef.current !== runtime.runtimeId
|
||||
) {
|
||||
completedRuntimeRef.current = runtime.runtimeId;
|
||||
setCompletedRuntimeId(runtime.runtimeId);
|
||||
setStatus("Quick answer ready.");
|
||||
const submitted = submittedRequestRef.current;
|
||||
if (submitted?.runtimeId === runtime.runtimeId) {
|
||||
setRequest((current) =>
|
||||
current === submitted.request ? "" : current,
|
||||
);
|
||||
setHistory((current) =>
|
||||
[
|
||||
{
|
||||
id: runtime.runtimeId,
|
||||
request: submitted.request,
|
||||
answer: text,
|
||||
},
|
||||
...current.filter((entry) => entry.id !== runtime.runtimeId),
|
||||
].slice(0, 5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
setExtension(snapshot.extensions[0]);
|
||||
@@ -227,7 +263,7 @@ export default function QuickPane() {
|
||||
}, [runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime) return;
|
||||
if (!runtime || completedRuntimeId === runtime.runtimeId) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setEscalation(
|
||||
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
|
||||
@@ -238,22 +274,32 @@ export default function QuickPane() {
|
||||
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
|
||||
}, settings.maximumSeconds * 1000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [runtime, settings.maximumSeconds]);
|
||||
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
|
||||
|
||||
async function run() {
|
||||
if (submissionStartingRef.current) return;
|
||||
if (invalid || !request.trim())
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
submissionStartingRef.current = true;
|
||||
setSubmissionStarting(true);
|
||||
const submittedRequest = request;
|
||||
try {
|
||||
const previous = runtimeRef.current;
|
||||
if (previous)
|
||||
await invoke("close_quick_runtime", {
|
||||
runtimeId: previous.runtimeId,
|
||||
}).catch(() => undefined);
|
||||
runtimeRef.current = undefined;
|
||||
submittedRequestRef.current = undefined;
|
||||
completedRuntimeRef.current = undefined;
|
||||
setRuntime(undefined);
|
||||
setCompletedRuntimeId(undefined);
|
||||
setSelectedHistoryId("");
|
||||
setError(undefined);
|
||||
setAnswer("");
|
||||
setEscalation(undefined);
|
||||
setExtension(undefined);
|
||||
setStatus("Starting advisory quick agent…");
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_quick_runtime",
|
||||
{
|
||||
@@ -261,6 +307,10 @@ export default function QuickPane() {
|
||||
},
|
||||
);
|
||||
runtimeRef.current = result.runtime;
|
||||
submittedRequestRef.current = {
|
||||
runtimeId: result.runtime.runtimeId,
|
||||
request: submittedRequest,
|
||||
};
|
||||
setRuntime(result.runtime);
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
@@ -271,17 +321,22 @@ export default function QuickPane() {
|
||||
agentId: result.runtime.agentId,
|
||||
level: thinkingLevel,
|
||||
});
|
||||
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${request}`;
|
||||
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${submittedRequest}`;
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: guardrails,
|
||||
});
|
||||
if (completedRuntimeRef.current !== result.runtime.runtimeId) {
|
||||
setStatus(
|
||||
"Quick request running. Advisory limits are not trusted enforcement.",
|
||||
);
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(`Could not start quick request: ${String(reason)}`);
|
||||
setStatus("");
|
||||
} finally {
|
||||
submissionStartingRef.current = false;
|
||||
setSubmissionStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +355,9 @@ export default function QuickPane() {
|
||||
}
|
||||
|
||||
async function continueFull() {
|
||||
if (invalid || !request.trim())
|
||||
const handoffRequest =
|
||||
request.trim() || submittedRequestRef.current?.request || "";
|
||||
if (invalid || !handoffRequest)
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
@@ -316,7 +373,7 @@ export default function QuickPane() {
|
||||
});
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: `${request}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
|
||||
message: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
|
||||
});
|
||||
setStatus("Full session started with follow-up model.");
|
||||
} catch (reason) {
|
||||
@@ -328,7 +385,28 @@ export default function QuickPane() {
|
||||
<main className="quick-pane">
|
||||
<header>
|
||||
<strong>Quick Pane</strong>
|
||||
<div className="quick-pane-header-controls">
|
||||
{history.length > 0 && (
|
||||
<label className="quick-pane-history">
|
||||
<span>History</span>
|
||||
<select
|
||||
aria-label="Answer history"
|
||||
value={selectedHistoryId}
|
||||
onChange={(event) =>
|
||||
setSelectedHistoryId(event.currentTarget.value)
|
||||
}
|
||||
>
|
||||
<option value="">Current answer</option>
|
||||
{history.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{historyLabel(entry.request)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button onClick={dismissPane}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
<textarea
|
||||
className="quick-pane-request"
|
||||
@@ -350,7 +428,7 @@ export default function QuickPane() {
|
||||
/>
|
||||
<section className="quick-pane-output" aria-live="polite">
|
||||
{status && <p>{status}</p>}
|
||||
{answer && <pre>{answer}</pre>}
|
||||
{displayedAnswer && <pre>{displayedAnswer}</pre>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{catalogError && <p role="alert">{catalogError}</p>}
|
||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||
@@ -398,10 +476,15 @@ export default function QuickPane() {
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
||||
disabled={
|
||||
submissionStarting ||
|
||||
!settingsLoaded ||
|
||||
!!invalid ||
|
||||
!request.trim()
|
||||
}
|
||||
onClick={run}
|
||||
>
|
||||
Ask
|
||||
{submissionStarting ? "Starting…" : "Ask"}
|
||||
</button>
|
||||
{canContinue && (
|
||||
<button className="quiet" onClick={continueFull}>
|
||||
|
||||
@@ -37,6 +37,7 @@ body {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #f0a347;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
@@ -44,6 +45,31 @@ body {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane-header-controls,
|
||||
.quick-pane-history {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quick-pane-header-controls {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.quick-pane-history {
|
||||
color: #bbb7b0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.quick-pane-history select {
|
||||
max-width: 240px;
|
||||
padding-block: 3px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.quick-pane button,
|
||||
.quick-pane select {
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user