fix(quick-pane): preserve lifecycle and answer history

This commit is contained in:
2026-08-25 23:08:45 +02:00
parent 1b0b34fad8
commit 4fc9c60ade
8 changed files with 506 additions and 65 deletions
+5 -10
View File
@@ -67,7 +67,8 @@ function normalizePiEvent(event) {
case "extension_ui_request": case "extension_ui_request":
return { type: "extension_ui_request", data: { event } }; return { type: "extension_ui_request", data: { event } };
case "extension_error": 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 } }; return { type: "transcript", data: { event } };
default: default:
return undefined; return undefined;
@@ -209,6 +210,7 @@ export function startPiRpcAdapter({
"unsupported_event", "unsupported_event",
`Pi RPC emitted unsupported event: ${event.type}`, `Pi RPC emitted unsupported event: ${event.type}`,
), ),
{ terminal: false },
); );
return; return;
} }
@@ -229,7 +231,6 @@ export function startPiRpcAdapter({
"frame_too_large", "frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`, `Pi RPC frame exceeds ${maxFrameBytes} bytes`,
), ),
{ terminal: false },
); );
const consumeStdout = (text) => { const consumeStdout = (text) => {
let remaining = text; let remaining = text;
@@ -303,10 +304,7 @@ export function startPiRpcAdapter({
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) { send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
if (!isRecord(commandInput) || typeof commandInput.type !== "string") { if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
return Promise.reject( return Promise.reject(
new PiRpcError( new PiRpcError("invalid_command", "Pi RPC command must include a type"),
"invalid_command",
"Pi RPC command must include a type",
),
); );
} }
if (Object.hasOwn(commandInput, "id")) { if (Object.hasOwn(commandInput, "id")) {
@@ -382,10 +380,7 @@ export function startPiRpcAdapter({
if (closed) return exited; if (closed) return exited;
intentionalStop = true; intentionalStop = true;
rejectPending( rejectPending(
new PiRpcError( new PiRpcError("child_stopped", "Pi RPC child was stopped by the bridge"),
"child_stopped",
"Pi RPC child was stopped by the bridge",
),
); );
child.kill("SIGTERM"); child.kill("SIGTERM");
return exited; return exited;
+30 -2
View File
@@ -162,7 +162,7 @@ test("sends abort without waiting behind a blocked prompt", async () => {
await registry.stop(); 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 worktrees = await createWorktrees();
const fixture = createAdapterFactory(); const fixture = createAdapterFactory();
const registry = createAgentRegistry({ const registry = createAgentRegistry({
@@ -173,7 +173,10 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
await registry.start(); await registry.start();
fixture.calls[0].options.onError( 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 }, { terminal: false },
); );
@@ -184,6 +187,31 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
await registry.stop(); 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 () => { test("stops agents created during shutdown and rejects new selections", async () => {
const worktrees = await createWorktrees(); const worktrees = await createWorktrees();
const fixture = createAdapterFactory(); const fixture = createAdapterFactory();
+50 -7
View File
@@ -115,10 +115,7 @@ test("allows a per-command timeout override without serializing it to Pi", async
type: "get_state", type: "get_state",
id: initialCommand.id, id: initialCommand.id,
}); });
await assert.rejects( await assert.rejects(adapter.send({ type: "get_session_stats" }), /timed out/);
adapter.send({ type: "get_session_stats" }),
/timed out/,
);
await adapter.stop(); await adapter.stop();
}); });
@@ -208,6 +205,49 @@ test("forwards extension responses without replacing Pi's request ID", async ()
await adapter.stop(); 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 () => { test("discards a fragmented oversized frame before processing the next frame", async () => {
const fixture = createFixture(); const fixture = createFixture();
const errors = []; const errors = [];
@@ -217,7 +257,7 @@ test("discards a fragmented oversized frame before processing the next frame", a
sessionDir: "/workspace/sessions", sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn, spawnProcess: fixture.spawn,
maxFrameBytes: 32, maxFrameBytes: 32,
onError: (error) => errors.push(error), onError: (error, metadata) => errors.push({ error, metadata }),
onEvent: (event) => events.push(event), onEvent: (event) => events.push(event),
}); });
const oversized = JSON.stringify({ const oversized = JSON.stringify({
@@ -230,8 +270,11 @@ test("discards a fragmented oversized frame before processing the next frame", a
); );
assert.deepEqual( assert.deepEqual(
errors.map((error) => error.code), errors.map(({ error, metadata }) => ({
["frame_too_large"], code: error.code,
terminal: metadata?.terminal !== false,
})),
[{ code: "frame_too_large", terminal: true }],
); );
assert.deepEqual(events, [ assert.deepEqual(events, [
{ {
+17 -3
View File
@@ -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, /get_session_runtime_snapshot/);
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/); 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, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/);
assert.match(source, /\{canContinue && \(/); 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.match(pane, /set_thinking_level/);
assert.doesNotMatch(pane, /showSettings/); assert.doesNotMatch(pane, /showSettings/);
assert.doesNotMatch(pane, /Save Quick Pane settings/); 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/); 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 () => { 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( readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8", "utf8",
@@ -85,8 +89,11 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
"utf8", "utf8",
), ),
readFile(new URL("../ui/src-tauri/tauri.conf.json", import.meta.url), "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 config = JSON.parse(configSource);
const capability = JSON.parse(capabilitySource);
const quickPane = config.app.windows.find((window) => window.label === "quick-pane"); const quickPane = config.app.windows.find((window) => window.label === "quick-pane");
assert.deepEqual( assert.deepEqual(
@@ -97,11 +104,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
}, },
{ width: 640, height: 440, transparent: true }, { 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-request"/);
assert.match(source, /className="quick-pane-output"/); assert.match(source, /className="quick-pane-output"/);
assert.match(source, /<footer className="quick-pane-controls">/); assert.match(source, /<footer className="quick-pane-controls">/);
assert.match(source, /ref=\{inputRef\}/); assert.match(source, /ref=\{inputRef\}/);
assert.match(source, /focusInput\(\)/); assert.match(source, /focusInput\(\)/);
assert.doesNotMatch(source, /if \(!event\.payload\)[\s\S]*?hide\(/);
assert.match( assert.match(
source, source,
/event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/, /event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
+66 -3
View File
@@ -3,7 +3,10 @@ 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::{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 LegacySubscription(Mutex<Option<JoinHandle<()>>>);
struct WorkspaceSubscription { 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") { 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)) { match quick_pane_action(window.is_visible().unwrap_or(false)) {
WindowAction::Hide => { WindowAction::Hide => {
let _ = window.hide(); let _ = window.hide();
@@ -72,7 +102,6 @@ fn toggle_quick_pane(app: &AppHandle) {
let _ = window.set_focus(); let _ = window.set_focus();
} }
} }
}
} }
fn requested_new_worktree_argument(args: &[String]) -> Option<String> { 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| { .setup(move |app| {
if requested_quick_pane(&launch_args) { if requested_quick_pane(&launch_args) {
toggle_quick_pane(app.handle()); toggle_quick_pane(app.handle());
@@ -504,6 +541,32 @@ mod tests {
assert_eq!(quick_pane_action(true), WindowAction::Hide); 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] #[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() { fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()]; let toggle = vec!["--toggle".to_owned()];
+191 -2
View File
@@ -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 userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QuickPaneSettings, UiState } from "../workspace/types"; import type { QuickPaneSettings, UiState } from "../workspace/types";
@@ -47,7 +54,18 @@ const uiState: UiState = {
quickPane: settings, 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.focusHandler = undefined; mocks.focusHandler = undefined;
@@ -115,4 +133,175 @@ describe("QuickPane model catalog errors", () => {
); );
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull(); 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);
});
}); });
+98 -15
View File
@@ -33,6 +33,12 @@ const defaults: QuickPaneSettings = {
}; };
type Runtime = { agentId: string; runtimeId: string }; 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) { function settingsError(settings: QuickPaneSettings) {
if (!settings.workspace.startsWith("/")) if (!settings.workspace.startsWith("/"))
@@ -74,6 +80,7 @@ export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults); const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [request, setRequest] = useState(""); const [request, setRequest] = useState("");
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 [selectedModel, setSelectedModel] = useState<ModelIdentity>({ const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
@@ -85,9 +92,17 @@ export default function QuickPane() {
const [catalogError, setCatalogError] = useState<string>(); const [catalogError, setCatalogError] = 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 [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [escalation, setEscalation] = useState<string>(); const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>(); const [extension, setExtension] = useState<Extension>();
const runtimeRef = useRef<Runtime | undefined>(undefined); 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 inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]); const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo( const modelOptions = useMemo(
@@ -99,6 +114,10 @@ export default function QuickPane() {
? modelIdentityValue(selectedModel) ? modelIdentityValue(selectedModel)
: ""; : "";
const canContinue = !!escalation || (!!runtime && !!error); const canContinue = !!escalation || (!!runtime && !!error);
const displayedAnswer = selectedHistoryId
? (history.find((entry) => entry.id === selectedHistoryId)?.answer ??
answer)
: answer;
const dismissPane = () => getCurrentWindow().hide(); const dismissPane = () => getCurrentWindow().hide();
const focusInput = () => inputRef.current?.focus(); const focusInput = () => inputRef.current?.focus();
@@ -170,15 +189,10 @@ export default function QuickPane() {
void loadModelCatalog(); void loadModelCatalog();
} }
}); });
const closeListener = paneWindow.onCloseRequested((event) => {
event.preventDefault();
void dismissPane();
});
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
void settingsListener.then((unlisten) => unlisten()); void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten());
void closeListener.then((unlisten) => unlisten());
}; };
}, [loadModelCatalog]); }, [loadModelCatalog]);
@@ -202,8 +216,30 @@ export default function QuickPane() {
text.replace("[ESCALATE]", "").trim() || text.replace("[ESCALATE]", "").trim() ||
"Quick agent requested a full session.", "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."); 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]); setExtension(snapshot.extensions[0]);
@@ -227,7 +263,7 @@ export default function QuickPane() {
}, [runtime]); }, [runtime]);
useEffect(() => { useEffect(() => {
if (!runtime) return; if (!runtime || completedRuntimeId === runtime.runtimeId) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
setEscalation( setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`, `Quick time budget of ${settings.maximumSeconds} seconds reached.`,
@@ -238,22 +274,32 @@ export default function QuickPane() {
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined); invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000); }, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [runtime, settings.maximumSeconds]); }, [completedRuntimeId, runtime, settings.maximumSeconds]);
async function run() { async function run() {
if (submissionStartingRef.current) return;
if (invalid || !request.trim()) if (invalid || !request.trim())
return setError(invalid ?? "Enter a request."); return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
try {
const previous = runtimeRef.current; const previous = runtimeRef.current;
if (previous) if (previous)
await invoke("close_quick_runtime", { await invoke("close_quick_runtime", {
runtimeId: previous.runtimeId, runtimeId: previous.runtimeId,
}).catch(() => undefined); }).catch(() => undefined);
runtimeRef.current = undefined;
submittedRequestRef.current = undefined;
completedRuntimeRef.current = undefined;
setRuntime(undefined);
setCompletedRuntimeId(undefined);
setSelectedHistoryId("");
setError(undefined); setError(undefined);
setAnswer(""); setAnswer("");
setEscalation(undefined); setEscalation(undefined);
setExtension(undefined); setExtension(undefined);
setStatus("Starting advisory quick agent…"); setStatus("Starting advisory quick agent…");
try {
const result = await invoke<{ runtime: Runtime }>( const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime", "create_quick_runtime",
{ {
@@ -261,6 +307,10 @@ export default function QuickPane() {
}, },
); );
runtimeRef.current = result.runtime; runtimeRef.current = result.runtime;
submittedRequestRef.current = {
runtimeId: result.runtime.runtimeId,
request: submittedRequest,
};
setRuntime(result.runtime); setRuntime(result.runtime);
await invoke("set_model", { await invoke("set_model", {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
@@ -271,17 +321,22 @@ export default function QuickPane() {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
level: thinkingLevel, 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", { await invoke("submit_prompt", {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
message: guardrails, message: guardrails,
}); });
if (completedRuntimeRef.current !== result.runtime.runtimeId) {
setStatus( setStatus(
"Quick request running. Advisory limits are not trusted enforcement.", "Quick request running. Advisory limits are not trusted enforcement.",
); );
}
} catch (reason) { } catch (reason) {
setError(`Could not start quick request: ${String(reason)}`); setError(`Could not start quick request: ${String(reason)}`);
setStatus(""); setStatus("");
} finally {
submissionStartingRef.current = false;
setSubmissionStarting(false);
} }
} }
@@ -300,7 +355,9 @@ export default function QuickPane() {
} }
async function continueFull() { async function continueFull() {
if (invalid || !request.trim()) const handoffRequest =
request.trim() || submittedRequestRef.current?.request || "";
if (invalid || !handoffRequest)
return setError(invalid ?? "Enter a request."); return setError(invalid ?? "Enter a request.");
try { try {
const result = await invoke<{ runtime: Runtime }>( const result = await invoke<{ runtime: Runtime }>(
@@ -316,7 +373,7 @@ export default function QuickPane() {
}); });
await invoke("submit_prompt", { await invoke("submit_prompt", {
agentId: result.runtime.agentId, 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."); setStatus("Full session started with follow-up model.");
} catch (reason) { } catch (reason) {
@@ -328,7 +385,28 @@ export default function QuickPane() {
<main className="quick-pane"> <main className="quick-pane">
<header> <header>
<strong>Quick Pane</strong> <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> <button onClick={dismissPane}>Close</button>
</div>
</header> </header>
<textarea <textarea
className="quick-pane-request" className="quick-pane-request"
@@ -350,7 +428,7 @@ export default function QuickPane() {
/> />
<section className="quick-pane-output" aria-live="polite"> <section className="quick-pane-output" aria-live="polite">
{status && <p>{status}</p>} {status && <p>{status}</p>}
{answer && <pre>{answer}</pre>} {displayedAnswer && <pre>{displayedAnswer}</pre>}
{error && <p role="alert">{error}</p>} {error && <p role="alert">{error}</p>}
{catalogError && <p role="alert">{catalogError}</p>} {catalogError && <p role="alert">{catalogError}</p>}
<ExtensionDialog extension={extension} onRespond={respond} /> <ExtensionDialog extension={extension} onRespond={respond} />
@@ -398,10 +476,15 @@ export default function QuickPane() {
</select> </select>
</label> </label>
<button <button
disabled={!settingsLoaded || !!invalid || !request.trim()} disabled={
submissionStarting ||
!settingsLoaded ||
!!invalid ||
!request.trim()
}
onClick={run} onClick={run}
> >
Ask {submissionStarting ? "Starting…" : "Ask"}
</button> </button>
{canContinue && ( {canContinue && (
<button className="quiet" onClick={continueFull}> <button className="quiet" onClick={continueFull}>
+26
View File
@@ -37,6 +37,7 @@ body {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
color: #f0a347; color: #f0a347;
font-size: 11px; font-size: 11px;
font-weight: 900; font-weight: 900;
@@ -44,6 +45,31 @@ body {
text-transform: uppercase; 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 button,
.quick-pane select { .quick-pane select {
min-width: 0; min-width: 0;