feat(quick-pane): add follow-up chat renewal

This commit is contained in:
2026-08-27 16:26:17 +02:00
parent 05874a0be1
commit 60fb91f375
14 changed files with 1147 additions and 549 deletions
+19 -13
View File
@@ -2,13 +2,13 @@
## Goal
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It accepts one short request and returns either a useful answer or an explicit escalation within 60 seconds.
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It keeps a lightweight chat for short requests and returns useful answers and offers an explicit manual full-session continuation when needed.
## Scope
- Cold-launchable, independent Tauri window.
- Compact request input, quick-model picker, and workspace picker.
- Configured default workspace, quick model, follow-up model, supplemental prompt, time limit, and read-only tool limit.
- Configured default workspace, quick model, follow-up model, supplemental prompt, and read-only tool-call guidance.
- Ephemeral Quick Pane UI state and best-effort runtime cleanup.
- Advisory read-only/no-subagent instructions, inline permission/clarification prompts, and explicit normal-session handoff.
@@ -25,23 +25,29 @@ Quick Pane is a small native window opened by a desktop/window-manager hotkey. I
Desktop/WM configuration owns the hotkey so it works while Pi GUI is absent. Settings displays and copies the required command; it does not register or modify system bindings.
The app creates or focuses a dedicated small Quick Pane window. `Esc` closes it and discards quick-only state.
The app creates or focuses a dedicated Quick Pane window. `Esc` and **Close** hide the window without clearing an idle runtime, draft, model catalog, or transcript. Reopening within three minutes preserves that chat. Focusing an idle chat after more than three minutes closes it and prepares a fresh runtime; active work is never renewed on this timer.
## Request lifecycle
1. Pane chooses configured default workspace and quick model, with per-request overrides.
2. It starts an isolated Pi RPC runtime in the chosen workspace.
3. Immutable prompt text instructs one request, read-only inspection, zero subagents, configured wall-clock budget, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions; event-derived caps and cleanup are best effort.
4. Supplemental user instructions append beneath those prompt guardrails.
5. Pane streams answer, tool status, and inline extension requests.
6. On success it displays final answer. On a cap it preserves partial answer, explains the cap, and offers handoff.
3. Immutable prompt text instructs short requests, read-only inspection, zero subagents, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions.
4. Supplemental user instructions append beneath those prompt guardrails. Pane maps each exact submitted raw prompt back to entered user text, so transcript rendering never parses delimiter-like text from instructions or requests. Unknown reflected messages with the Quick Pane advisory prefix are hidden.
5. Pane streams the full ordered user/assistant transcript, tool status, and inline extension requests. Follow-ups reuse the same runtime until explicit or stale renewal. Starting each follow-up clears only prior turn's handoff answer state; transcript messages remain visible.
6. After first completed assistant answer, it displays a prominent manual **Continue in full session** action. Escalation and runtime-error notices remain visible and also offer handoff. No answer triggers a full session automatically.
## Chat reset and layout
`Ctrl+N` starts a new chat: it aborts active work when needed, closes the old runtime with retryable cleanup, clears draft/transcript and submitted-prompt mappings, then prepares a fresh runtime and model catalog. Automatic stale renewal performs the same idle-chat replacement while preserving the current draft.
Quick Pane opens at 640×360 with a 520×360 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped.
## Escalation
Mutations, subagents, multi-step work, and likely-over-budget work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
Mutations, subagents, multi-step work, and complex work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
- original request;
- escalation note or partial findings;
- transcript-derived current-chat context and escalation note or partial findings;
- selected workspace; and
- configured follow-up model.
@@ -51,16 +57,16 @@ No full session opens automatically.
Quick Pane surfaces Pi extension `select`, `confirm`, `input`, and `editor` requests inline. User explicitly answers every prompt. Existing policy remains authoritative: ordinary workspace reads may be allowed, external-directory reads may ask, and sensitive paths may deny.
Quick Pane UI request, output, and transcript stay in memory only. Closing pane removes them and triggers best-effort quick-runtime cleanup. Pi may write temporary session data while the child runs. A deliberate handoff persists only in newly created normal-session history.
Quick Pane UI request, output, and transcript stay in memory only. Hiding the pane preserves an idle chat for up to three minutes. `Ctrl+N`, stale idle renewal, app teardown, or another explicit chat replacement clears in-memory chat state and requests best-effort quick-runtime cleanup. Pi may write temporary session data while the child runs. A deliberate handoff persists only in newly created normal-session history.
## Failure handling
Unavailable models, startup failures, and crashes render inline actionable errors: retry, pick another quick model, or open normal session. No silent fallback model is used.
Settings-startup and model-catalog preparation errors render an inline **Retry** action. Retry completes failed-runtime cleanup before preparing a replacement; model selection and Send remain disabled until a valid runtime catalog is ready. Request/runtime failures offer **Continue** only when a valid request and handoff configuration exist. An empty catalog remains visible with model selection and Send disabled. No silent fallback model is used.
## Settings validation
Settings reject invalid model, workspace, custom prompt, or numeric limit. Failed save keeps previously stored configuration and marks invalid fields.
Settings reject invalid model, workspace, custom prompt, or tool-call limit. Failed save keeps previously stored configuration and marks invalid fields.
## Acceptance
From cold or hot desktop state, the hotkey produces an answer or explicit escalation within 60 seconds. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
From cold or hot desktop state, Quick Pane starts an unlimited chat without a time-based abort. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
+12 -8
View File
@@ -14,28 +14,32 @@
## 3. Build Quick Pane React surface
- Add isolated state reducer/component for request, selected model/workspace, progress, final answer, partial answer, escalation, extension UI, and errors.
- Keep pane transcript in component memory only and request best-effort runtime/session cleanup on close.
- Add isolated state/component logic for request, selected model/workspace, progress, full ordered transcript, current-turn partial answer, escalation, extension UI, and errors. Clear prior answer/handoff state before every follow-up while retaining transcript messages.
- Keep pane transcript and exact raw-prompt-to-visible-request mappings in component memory only. Hide unknown reflected Quick Pane advisory prompts rather than delimiter-parsing them.
- Make `Esc` and **Close** native hide actions. Preserve idle runtime, draft, catalog, and transcript when reopened within three minutes; renew stale idle chats on focus.
- Add `Ctrl+N` reset semantics: abort active work, close with cleanup retry, clear draft/transcript/prompt mappings, and prepare a fresh catalog/runtime.
- Open native window at 640×360 with a 520×360 minimum; use ResizeObserver/RAF content measurement to request native growth only up to 520px tall, then use internal scrolling.
- Add visible Retry recovery for settings-startup and model-catalog preparation failures. Finish failed-runtime cleanup before replacement preparation, and keep model/Send disabled until ready.
- Add Settings controls and copyable WM launch command.
## 4. Add constrained bridge runtime mode
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
- Use observed tool events and wall-clock cancellation for best-effort caps; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
- Instruct preflight escalation for mutation, subagent, multi-step, or likely-over-budget requests.
- Use advisory tool-call guidance only; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
- Instruct preflight escalation for mutation, subagent, multi-step, or complex requests.
## 5. Route interaction and handoff
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
- On explicit continuation, create normal runtime using workspace and follow-up model.
- Prefill it with original request plus escalation note/partial findings.
- After first completed assistant answer, show manual continuation with valid handoff configuration; never open a session automatically.
- Prefill normal session with transcript-derived current-chat context, workspace, and follow-up model.
## 6. Test and document
- Add unit tests for settings validation/persistence and guardrail classification.
- Add component tests for pane states, picker behavior, inline prompts, error/retry, and cleanup.
- Add Rust/bridge tests for window command routing, budget cancellation, ephemeral cleanup, and handoff payload.
- Add component tests for pane states, full transcript and exact prompt reflection, picker behavior, inline prompts, hide/renew/reset behavior, runtime and extension errors, stale-created cleanup retry, and handoff.
- Add Rust/bridge tests for window command routing, bounded content resizing, ephemeral cleanup, and handoff payload.
- Add manual WM hotkey/cold-launch instructions and smoke checklist.
## Sequencing
+18 -11
View File
@@ -6,36 +6,43 @@
- Valid defaults load when no Quick Pane configuration exists.
- Valid configuration round-trips through native storage.
- Invalid workspace, model, prompt, time, and tool values show field errors and retain prior saved state.
- Invalid workspace, model, prompt, and tool-call values show field errors and retain prior saved state.
- Quick model picker affects quick request only; follow-up model remains configured default.
### Runtime guardrails
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
- Mutation, subagent, multi-step, and likely-over-budget requests are instructed to preflight-escalate.
- Runtime applies best-effort configured 60-second/6-tool caps from observed events and exposes reason.
- Partial result survives a cap; pane transcript is removed and runtime cleanup requested after pane close.
- Mutation, subagent, multi-step, and complex requests are instructed to preflight-escalate.
- Quick Pane has no time-based abort; configured tool-call guidance remains prompt-only.
- Full ordered user/assistant transcript remains visible for follow-ups. Each follow-up clears prior answer/partial-handoff state before model, thinking, or prompt submission.
- Reflected user messages are resolved by exact submitted raw-prompt mapping. Supplemental instructions and entered requests containing `\n\nUser request:` render only the exact entered multiline request; unknown Quick Pane advisory prompts are hidden and ordinary user text remains visible.
- Tests/documentation state current limitations: Pi RPC cannot trusted-enforce prompt policy or guarantee memory-only temporary sessions.
### UI and bridge
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
- Workspace and quick-model picker values reach runtime creation.
- Permission and clarification extension requests render inline and send correct response payloads.
- Retry, model change, and normal-session actions appear for startup/model/crash errors.
- Handoff creates normal session with original request, escalation note, workspace, and follow-up model.
- Permission and clarification extension requests remain visible inline and send correct response payloads; runtime errors do not erase partial transcript or pending extensions.
- `Esc` and **Close** hide without cleanup. Reopening an idle chat at or under three minutes preserves runtime, draft/catalog, and transcript; reopening after more than three minutes renews it.
- `Ctrl+N` prevents browser default, aborts active work when needed, retries queued cleanup, clears draft/transcript/raw-prompt mappings, and prepares a fresh runtime/catalog. Idle reset does not send an abort.
- Stale runtimes created by superseded preparation are queued after cleanup failure and retried before later stale-chat renewal.
- Quick Pane opens at 640×360 with a 520×360 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that.
- Settings-startup and model-catalog errors show **Retry**. Catalog retry cleans up failed runtime before replacement preparation; model selection and Send stay disabled until ready.
- **Continue in full session** appears after first completed assistant answer with valid handoff settings, plus preserved request/runtime escalation notices. It never starts automatically. Handoff creates normal session with transcript-derived current-chat context, workspace, and follow-up model. Current answer extraction starts only after exact mapped submitted prompt in snapshot transcript; failed follow-ups with no current assistant message never reuse prior answer as partial findings.
## Manual smoke checklist
1. Configure default workspace, models, prompt, and 60-second/6-tool limits.
1. Configure default workspace, models, prompt, and tool-call guidance.
2. Add desktop/WM hotkey using Settings launch command.
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
4. Submit short answer-only request; verify result within 60 seconds.
4. Submit a short answer-only request; verify no time-based abort and visible manual **Continue in full session** after completion.
5. Submit read-only inspection request; answer any external-read permission prompt inline.
6. Submit mutation or multi-step request; verify reason and **Continue in full session**.
7. Continue; verify normal session receives handoff and follow-up model.
8. Close Quick Pane; reopen and verify quick transcript is absent.
9. Select unavailable model or induce startup failure; verify inline recovery options.
8. Close Quick Pane and reopen within three minutes; verify idle runtime, draft/catalog, and full transcript remain. Repeat after more than three idle minutes; verify fresh chat/runtime appears.
9. Press `Ctrl+N` while idle and active; verify fresh empty chat in both cases and active work aborts before replacement.
10. Verify pane opens at 640×360, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally.
11. Induce settings-startup or model-catalog failure; verify visible **Retry**, disabled model/Send controls until recovery, and successful retry. Verify empty catalog stays blocked without unsupported actions.
## Exit criteria
+74 -12
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("Quick Pane polls snapshots, renders answers, and gates handoff", async () => {
test("Quick Pane polls snapshots, renders answers, and gates manual transcript handoff", async () => {
const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
@@ -11,10 +11,10 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async ()
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(source, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
assert.match(source, /className="quick-pane-notice escalation"/);
assert.match(
source,
/<button type="button" onClick=\{continueFull\}>\s*Continue\s*<\/button>/,
);
assert.match(source, /className="quick-pane-notice continue"/);
assert.match(source, /Continue in full session/);
assert.match(source, /Quick Pane transcript:/);
assert.doesNotMatch(source, /maximumSeconds|Quick time budget/);
});
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
@@ -46,19 +46,46 @@ test("main Settings uses shared catalog selectors with provider derived from mod
assert.match(pane, /preparedRuntimeRef/);
assert.doesNotMatch(pane, /get_model_catalog/);
assert.match(pane, /set_thinking_level/);
assert.match(pane, /Loading models…/);
assert.match(pane, /catalogStatus !== "ready"/);
assert.doesNotMatch(pane, /showSettings/);
assert.doesNotMatch(pane, /Save Quick Pane settings/);
assert.match(pane, /isStreaming === false/);
assert.match(pane, /!snapshotIsActive\(snapshot\)/);
assert.match(pane, /current === submitted\.request \? "" : current/);
assert.match(pane, /readOnly=\{runActive\}/);
assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/);
assert.match(pane, /\.slice\(0, 5\)/);
assert.match(pane, /aria-label="Answer history"/);
assert.doesNotMatch(pane, /\.slice\(0, 5\)/);
assert.doesNotMatch(pane, /aria-label="Answer history"/);
assert.match(
pane,
/transcriptMessages\(snapshot, submittedPromptRequestsRef\.current\)/,
);
assert.match(pane, /const QUICK_PANE_ADVISORY_PREFIX =/);
assert.match(pane, /submittedPromptRequests\.get\(text\)/);
assert.match(
pane,
/text\.startsWith\(QUICK_PANE_ADVISORY_PREFIX\) \? "" : text/,
);
assert.match(
pane,
/submittedPromptRequestsRef\.current\.set\(guardrails, submittedRequest\)/,
);
assert.match(pane, /submittedPromptRequestsRef\.current\.clear\(\)/);
assert.doesNotMatch(
pane,
/delimiterIndex|text\.indexOf\(USER_REQUEST_DELIMITER\)/,
);
assert.match(
pane,
/message\.role !== "user" && message\.role !== "assistant"/,
);
assert.match(pane, /lastUserQuestionAtRef\.current = submittedAt/);
assert.match(pane, /lastCompletedAnswerAtRef\.current = Date\.now\(\)/);
assert.doesNotMatch(pane, /localStorage|sessionStorage|save_ui_state/);
assert.match(persistence, /quickPane: current\.quickPane/);
});
test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on focus", async () => {
test("Quick Pane keeps slash-containing model IDs atomic and renews catalog on focus", async () => {
const [pane, catalog] = await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
@@ -70,7 +97,16 @@ test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on
assert.match(pane, /provider: submittedModel\.provider/);
assert.match(pane, /modelId: submittedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /onFocusChanged[\s\S]*?void prepareRuntime\(\)/);
assert.match(pane, /onFocusChanged[\s\S]*?void renewChatOnFocus\(\)/);
assert.match(pane, /if \(!event\.payload\) return;/);
assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/);
assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/);
assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/);
assert.match(pane, /event\.preventDefault\(\)/);
assert.doesNotMatch(
pane,
/setInterval[^\n]*CHAT_RENEWAL_MS|setTimeout[^\n]*CHAT_RENEWAL_MS/,
);
assert.match(
catalog,
/JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/,
@@ -88,6 +124,11 @@ test("Quick Pane dismiss and mutually exclusive window visibility stay native-se
]);
assert.match(pane, /invoke\("dismiss_quick_pane"\)/);
assert.doesNotMatch(pane, /getCurrentWindow\(\)\.hide\(\)/);
const dismissPane = pane.slice(
pane.indexOf("const dismissPane"),
pane.indexOf("const clearChatState"),
);
assert.doesNotMatch(dismissPane, /closeRuntime|clearRuntime|prepareRuntime/);
assert.match(nativeSource, /fn dismiss_quick_pane\(/);
assert.match(
nativeSource,
@@ -165,9 +206,17 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
{
width: quickPane.width,
height: quickPane.height,
minWidth: quickPane.minWidth,
minHeight: quickPane.minHeight,
transparent: quickPane.transparent,
},
{ width: 640, height: 440, transparent: true },
{
width: 640,
height: 360,
minWidth: 520,
minHeight: 360,
transparent: true,
},
);
assert.ok(capability.windows.includes("quick-pane"));
assert.match(nativeSource, /WindowEvent::CloseRequested/);
@@ -177,6 +226,10 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
/WebviewWindowBuilder::from_config\(app, config\)/,
);
assert.match(nativeSource, /get_or_create_quick_pane/);
assert.match(nativeSource, /fn resize_quick_pane/);
assert.match(nativeSource, /height\.clamp\(360\.0, 520\.0\)/);
assert.match(source, /new ResizeObserver\(resizeToContent\)/);
assert.match(source, /invoke\("resize_quick_pane", \{ height \}\)/);
assert.doesNotMatch(source, /onCloseRequested/);
assert.match(source, /className="quick-pane-request"/);
assert.match(source, /className="quick-pane-exchange"/);
@@ -200,7 +253,8 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
);
assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/);
assert.match(stylesheet, /@media \(max-width: 400px\)/);
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: visible/);
assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/);
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: hidden/);
assert.doesNotMatch(
stylesheet,
/\.quick-pane-primary-controls\s*\{[^}]*overflow:\s*(?:hidden|clip)/,
@@ -227,4 +281,12 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
selectorStylesheet,
/\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/,
);
assert.match(
selectorStylesheet,
/\.model-menu\s*\{[\s\S]*?grid-template-rows:\s*auto auto minmax\(0, 1fr\);[\s\S]*?overflow:\s*hidden/,
);
assert.match(
selectorStylesheet,
/\.model-results\s*\{[\s\S]*?overflow-y:\s*auto/,
);
});
+34 -2
View File
@@ -9,7 +9,7 @@ use std::{
time::Duration,
};
use tauri::{
async_runtime::JoinHandle, AppHandle, Emitter, Manager, State, WebviewWindow,
async_runtime::JoinHandle, AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow,
WebviewWindowBuilder, WindowEvent,
};
@@ -283,6 +283,28 @@ async fn get_model_catalog() -> Result<Value, String> {
bridge::get_model_catalog(&socket_path()?).await
}
fn quick_pane_height(height: f64) -> f64 {
height.clamp(360.0, 520.0)
}
#[tauri::command]
fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
if !height.is_finite() {
return Err("Quick Pane height must be finite".to_owned());
}
let quick_pane = app
.get_webview_window("quick-pane")
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
let scale_factor = quick_pane.scale_factor().map_err(|error| error.to_string())?;
let current_size = quick_pane.inner_size().map_err(|error| error.to_string())?;
quick_pane
.set_size(LogicalSize::new(
f64::from(current_size.width) / scale_factor,
quick_pane_height(height),
))
.map_err(|error| error.to_string())
}
#[tauri::command]
fn dismiss_quick_pane(
app: AppHandle,
@@ -601,6 +623,7 @@ pub fn run() {
get_workspace,
get_workspace_summary,
get_model_catalog,
resize_quick_pane,
dismiss_quick_pane,
create_session_runtime,
create_quick_runtime,
@@ -767,7 +790,9 @@ mod tests {
.unwrap();
assert_eq!(quick_pane.width, 640.0);
assert_eq!(quick_pane.height, 440.0);
assert_eq!(quick_pane.height, 360.0);
assert_eq!(quick_pane.min_width, Some(520.0));
assert_eq!(quick_pane.min_height, Some(360.0));
assert_eq!(quick_pane.url.to_string(), "index.html");
assert!(!quick_pane.visible);
assert!(quick_pane.transparent);
@@ -775,6 +800,13 @@ mod tests {
assert!(quick_pane.skip_taskbar);
}
#[test]
fn quick_pane_height_stays_within_content_limit() {
assert_eq!(quick_pane_height(200.0), 360.0);
assert_eq!(quick_pane_height(420.0), 420.0);
assert_eq!(quick_pane_height(900.0), 520.0);
}
#[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()];
+30 -8
View File
@@ -46,7 +46,6 @@ pub struct QuickPaneSettings {
pub follow_up_provider: String,
pub follow_up_model: String,
pub instructions: String,
pub maximum_seconds: u32,
pub maximum_tool_calls: u32,
}
@@ -58,8 +57,7 @@ impl Default for QuickPaneSettings {
model: String::new(),
follow_up_provider: String::new(),
follow_up_model: String::new(),
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
maximum_seconds: 60,
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.".to_owned(),
maximum_tool_calls: 6,
}
}
@@ -232,9 +230,8 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
}
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
{
return Err("Quick-pane limits are outside the supported range".to_owned());
if !(1..=64).contains(&quick.maximum_tool_calls) {
return Err("Quick-pane tool-call limit is outside the supported range".to_owned());
}
total_string_bytes = total_string_bytes
.saturating_add(quick.workspace.len())
@@ -281,7 +278,16 @@ pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
if content.len() > MAX_FILE_BYTES {
return Err("UI state file exceeds the size limit".to_owned());
}
let state: UiStateV1 = serde_json::from_slice(&content)
let mut value: serde_json::Value = serde_json::from_slice(&content)
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
// Read legacy persisted cap once, but never retain or serialize it again.
if let Some(quick_pane) = value
.get_mut("quickPane")
.and_then(serde_json::Value::as_object_mut)
{
quick_pane.remove("maximumSeconds");
}
let state: UiStateV1 = serde_json::from_value(value)
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
validate(&state)?;
Ok(state)
@@ -443,11 +449,27 @@ mod tests {
state.quick_pane.follow_up_provider = "provider".into();
state.quick_pane.follow_up_model = "follow-up-model".into();
assert!(validate(&state).is_ok());
state.quick_pane.maximum_seconds = 0;
state.quick_pane.maximum_tool_calls = 0;
assert!(validate(&state).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_maximum_seconds_loads_but_is_not_serialized() {
let root = temp_dir("quick-pane-legacy-seconds");
fs::create_dir_all(&root).unwrap();
let path = state_path(&root);
fs::write(
&path,
r#"{"version":1,"quickPane":{"workspace":"","provider":"","model":"","followUpProvider":"","followUpModel":"","instructions":"valid","maximumSeconds":60,"maximumToolCalls":6}}"#,
)
.unwrap();
let state = load_from(&root).unwrap();
save_to(&root, &state).unwrap();
assert!(!fs::read_to_string(&path).unwrap().contains("maximumSeconds"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
let root = temp_dir("replace");
+3 -3
View File
@@ -29,9 +29,9 @@
"label": "quick-pane",
"title": "Pi Quick Pane",
"width": 640,
"height": 440,
"minWidth": 400,
"minHeight": 260,
"height": 360,
"minWidth": 520,
"minHeight": 360,
"center": true,
"visible": false,
"decorations": false,
-11
View File
@@ -146,7 +146,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "gpt-5",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -222,7 +221,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "old-model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
});
@@ -284,7 +282,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "old-model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -359,7 +356,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "old-model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -453,7 +449,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -491,7 +486,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "old-model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -551,7 +545,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "old-model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -626,7 +619,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "old-model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -700,7 +692,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -772,7 +763,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
@@ -828,7 +818,6 @@ describe("App command progress and errors", () => {
followUpProvider: "openai",
followUpModel: "model",
instructions: "Stay concise.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
+1 -23
View File
@@ -35,8 +35,7 @@ const defaultQuickPaneSettings: QuickPaneSettings = {
followUpProvider: "",
followUpModel: "",
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
maximumSeconds: 60,
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.",
maximumToolCalls: 6,
};
function validateQuickPaneSettings(settings: QuickPaneSettings) {
@@ -48,12 +47,6 @@ function validateQuickPaneSettings(settings: QuickPaneSettings) {
return "Follow-up provider and model are required.";
if (!settings.instructions.trim())
return "Supplemental instructions are required.";
if (
!Number.isInteger(settings.maximumSeconds) ||
settings.maximumSeconds < 1 ||
settings.maximumSeconds > 600
)
return "Seconds must be an integer from 1 to 600.";
if (
!Number.isInteger(settings.maximumToolCalls) ||
settings.maximumToolCalls < 1 ||
@@ -1118,21 +1111,6 @@ export default function App() {
/>
<small>Provider comes from selected catalog model.</small>
</div>
<label>
Maximum seconds
<input
type="number"
min="1"
max="600"
value={quickPaneSettings.maximumSeconds}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
maximumSeconds: Number(event.currentTarget.value),
})
}
/>
</label>
<label>
Maximum tool calls
<input
+3
View File
@@ -47,9 +47,11 @@
left: 0;
z-index: 30;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
box-sizing: border-box;
width: min(400px, calc(100vw - 40px));
max-height: min(420px, calc(100vh - 80px));
overflow: hidden;
gap: 6px;
padding: 8px;
border: 1px solid #70522f;
@@ -79,6 +81,7 @@
display: grid;
gap: 7px;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.model-provider-group {
+510 -208
View File
@@ -9,6 +9,7 @@ import {
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
Message,
Model,
QuickPaneSettings,
RuntimeSnapshot,
@@ -39,7 +40,6 @@ const settings: QuickPaneSettings = {
followUpProvider: "provider",
followUpModel: "full/model",
instructions: "Stay read-only.",
maximumSeconds: 60,
maximumToolCalls: 6,
};
const uiState: UiState = {
@@ -83,6 +83,16 @@ function runtimeSnapshot(
};
}
function snapshotWithTranscript(
messages: Message[],
isStreaming = false,
runtime: RuntimeSnapshot["runtime"] = runtimeSnapshot(defaultModels).runtime,
) {
const snapshot = runtimeSnapshot(defaultModels, "", isStreaming, runtime);
snapshot.transcript = { data: { messages } };
return snapshot;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -101,8 +111,12 @@ function bridgeMock(
) {
let runtimeCount = 0;
const submitted = new Set<string>();
const submittedPrompts = new Map<string, string>();
mocks.invoke.mockImplementation(
(command: string, payload?: { runtimeId?: string; agentId?: string }) => {
(
command: string,
payload?: { runtimeId?: string; agentId?: string; message?: string },
) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "create_quick_runtime") {
runtimeCount += 1;
@@ -114,14 +128,27 @@ function bridgeMock(
});
}
if (command === "submit_prompt") {
if (payload?.agentId) submitted.add(payload.agentId);
if (payload?.agentId) {
submitted.add(payload.agentId);
if (payload.message)
submittedPrompts.set(payload.agentId, payload.message);
}
return Promise.resolve(undefined);
}
if (command === "get_session_runtime_snapshot") {
const segments = payload?.runtimeId?.split("-") ?? [];
const index = Number(segments[segments.length - 1] ?? 1) - 1;
if (submitted.has(`agent-${index + 1}`) && activeSnapshot)
return Promise.resolve(activeSnapshot(index));
return Promise.resolve(activeSnapshot(index)).then((snapshot) => {
const messages = snapshot.transcript?.data?.messages;
const prompt = submittedPrompts.get(`agent-${index + 1}`);
if (
prompt &&
messages?.every((message) => message.role === "assistant")
)
messages.unshift({ role: "user", content: prompt });
return snapshot;
});
return Promise.resolve(
runtimeSnapshot(
catalogs[index] ?? catalogs[catalogs.length - 1] ?? [],
@@ -182,6 +209,7 @@ async function ask(text = "Inspect") {
describe("QuickPane", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
mocks.focusHandler = undefined;
mocks.listen.mockResolvedValue(vi.fn());
@@ -205,24 +233,23 @@ describe("QuickPane", () => {
]);
});
it("keeps downward model menu unclipped while trigger text stays truncatable", async () => {
it("keeps downward model menu inside the scroll-contained pane", async () => {
const user = userEvent.setup();
bridgeMock([defaultModels]);
const { container } = render(<QuickPane />);
const trigger = await preparedTrigger();
await user.click(trigger);
const menu = screen.getByRole("dialog", { name: "Choose model" });
let ancestor = menu.parentElement;
while (ancestor && ancestor !== document.body) {
expect(getComputedStyle(ancestor).overflow).not.toMatch(/hidden|clip/);
ancestor = ancestor.parentElement;
}
const value = trigger.querySelector("span");
expect(value).toHaveTextContent("provider · Quick Model");
expect(trigger).toHaveClass("model-trigger");
expect(container.querySelector(".model-control-down .model-menu")).toBe(
menu,
);
expect(menu).toHaveClass("model-menu");
expect(
screen.getByRole("listbox", { name: "Available models" }),
).toHaveClass("model-results");
});
it("closes an open selector on first Escape and dismisses pane on second", async () => {
@@ -320,48 +347,239 @@ describe("QuickPane", () => {
expect(input).toHaveValue("Next draft");
});
it("keeps five answers and previews history without changing composer draft", async () => {
const tracker = bridgeMock(
Array.from({ length: 6 }, () => defaultModels),
(index) => runtimeSnapshot(defaultModels, `Answer ${index + 1}`, false),
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
bridgeMock([defaultModels], () =>
snapshotWithTranscript([
{ role: "user", content: "Inspect" },
{ role: "assistant", content: "Completed answer" },
]),
);
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "create_session_runtime")
return Promise.resolve({
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
});
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
await ask("Inspect");
const continueButton = await screen.findByRole("button", {
name: "Continue in full session",
});
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_session_runtime",
expect.anything(),
);
await userEvent.click(continueButton);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-full",
message:
"Inspect\n\nQuick Pane handoff: Continue this chat in a full session.\n\nQuick Pane transcript:\nUser: Inspect\n\nAssistant: Completed answer",
}),
);
});
it("keeps unlimited follow-ups on one runtime and renders snapshot messages in order", async () => {
let turn = 0;
const tracker = bridgeMock([defaultModels], () => {
const messages: Message[] = [
{ role: "user", content: "**First request**" },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "private reasoning" },
{ type: "text", text: "First answer" },
{ type: "toolCall", name: "read" },
],
},
{ role: "toolResult", content: "private tool output" },
];
if (turn > 1)
messages.push(
{ role: "user", content: "Second request" },
{ role: "assistant", content: "## Second answer" },
);
return snapshotWithTranscript(messages);
});
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "submit_prompt") turn += 1;
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
const input = screen.getByRole("textbox", { name: "Request" });
for (let index = 1; index <= 6; index += 1) {
await ask(`Request ${index}`);
expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
}
expect(tracker.runtimeCount()).toBe(6);
const history = screen.getByRole("combobox", { name: "Answer history" });
expect(within(history).getAllByRole("option")).toHaveLength(6);
await ask("First");
expect(await screen.findByText("First answer")).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
await ask("Second");
expect(
within(history).queryByRole("option", { name: "Request 1" }),
).toBeNull();
await screen.findByRole("heading", { name: "Second answer" }),
).toBeInTheDocument();
await userEvent.type(input, "Composer draft");
await userEvent.selectOptions(history, "runtime-2");
const exchange = screen.getByRole("region", { name: "Quick exchange" });
expect(within(exchange).getByText("Request 2")).toBeInTheDocument();
expect(within(exchange).getByText("Answer 2")).toBeInTheDocument();
expect(input).toHaveValue("Composer draft");
expect(tracker.runtimeCount()).toBe(1);
expect(
within(exchange).getByRole("region", { name: "Assistant response" }),
).toHaveAttribute("aria-live", "off");
mocks.invoke.mock.calls.filter(
([command]) => command === "submit_prompt",
),
).toEqual([
["submit_prompt", expect.objectContaining({ agentId: "agent-1" })],
["submit_prompt", expect.objectContaining({ agentId: "agent-1" })],
]);
expect(screen.queryByText("private reasoning")).toBeNull();
expect(screen.queryByText("private tool output")).toBeNull();
expect(
screen.queryByRole("combobox", { name: "Answer history" }),
).toBeNull();
const cards = screen
.getByRole("region", { name: "Assistant response" })
.querySelectorAll("article");
expect(Array.from(cards).map((card) => card.textContent)).toEqual([
"YouFirst request",
"AssistantFirst answer",
"YouSecond request",
"AssistantSecond answer",
]);
});
it("keeps runtime error and extension request visible in history preview", async () => {
it("does not use prior-turn answer after follow-up runtime failure", async () => {
const submittedPrompts: string[] = [];
bridgeMock([defaultModels], () => {
const messages: Message[] = [
{ role: "user", content: submittedPrompts[0] ?? "" },
{ role: "assistant", content: "First answer" },
];
if (submittedPrompts[1])
messages.push({ role: "user", content: submittedPrompts[1] });
return snapshotWithTranscript(
messages,
false,
submittedPrompts[1]
? {
...runtimeSnapshot(defaultModels).runtime,
error: { message: "Second runtime failed." },
}
: undefined,
);
});
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "create_session_runtime")
return Promise.resolve({
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
});
if (command === "submit_prompt" && payload?.agentId === "agent-1")
submittedPrompts.push(String(payload.message));
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
await ask("First");
expect(await screen.findByText("First answer")).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByRole("textbox", { name: "Request" })).toHaveValue(""),
);
await ask("Second");
expect(
await screen.findByText("Second runtime failed."),
).toBeInTheDocument();
expect(screen.getByText("First answer")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
worktreePath: "/repo",
}),
);
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-full",
message:
"Second\n\nQuick Pane handoff: Quick runtime failed. Continue in a full session if needed.\n\nQuick Pane transcript:\nUser: First\n\nAssistant: First answer\n\nUser: Second",
});
});
it("maps the exact submitted payload to the entered multiline request despite delimiter collisions", async () => {
const supplementalInstructions =
"Stay read-only.\n\nUser request: supplemental collision.";
const enteredRequest =
"First line\n\nUser request: request collision.\nThird **bold** line";
const collisionState: UiState = {
...uiState,
quickPane: { ...settings, instructions: supplementalInstructions },
};
let reflectedPrompt = "";
bridgeMock([defaultModels], () =>
snapshotWithTranscript([
{ role: "user", content: "Normal earlier user text" },
{
role: "user",
content: "[Quick Pane advisory mode: unknown reflected prompt]",
},
{ role: "user", content: reflectedPrompt },
{ role: "assistant", content: "Done" },
]),
);
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "load_ui_state") return Promise.resolve(collisionState);
if (command === "submit_prompt")
reflectedPrompt = String(payload?.message ?? "");
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
fireEvent.change(screen.getByRole("textbox", { name: "Request" }), {
target: { value: enteredRequest },
});
await userEvent.click(screen.getByRole("button", { name: "Send request" }));
const expectedPayload = `[Quick Pane advisory mode: use at most 6 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 tool calls, start final response with [ESCALATE] and explain why.]\n${supplementalInstructions}\n\nUser request: ${enteredRequest}`;
await waitFor(() => expect(reflectedPrompt).toBe(expectedPayload));
await screen.findByText("Done");
const userArticles = document.querySelectorAll(".quick-pane-user-message");
expect(userArticles).toHaveLength(2);
expect(userArticles[0]).toHaveTextContent("YouNormal earlier user text");
expect(userArticles[1]).toHaveTextContent(
/YouFirst line\s+User request: request collision\.\s+Third bold line/,
);
expect(screen.queryByText(/unknown reflected prompt/)).toBeNull();
expect(userArticles[1]).not.toHaveTextContent("supplemental collision");
expect(userArticles[1]).not.toHaveTextContent("Quick Pane advisory mode");
});
it("keeps runtime errors and extension requests visible", async () => {
let failed = false;
bridgeMock([defaultModels, defaultModels], (index) => {
if (index === 0)
return runtimeSnapshot(defaultModels, "First answer", false);
const result = runtimeSnapshot(defaultModels, "", true, {
...runtimeSnapshot(defaultModels).runtime,
activeTool: "functions.read",
error: failed ? { message: "Runtime disconnected." } : undefined,
});
result.extensions = [
bridgeMock([defaultModels], () => {
const snapshot = snapshotWithTranscript(
[
{ role: "user", content: "Inspect" },
{ role: "assistant", content: "Partial answer" },
],
true,
{
...runtimeSnapshot(defaultModels).runtime,
activeTool: "functions.read",
error: failed ? { message: "Runtime disconnected." } : undefined,
},
);
snapshot.extensions = [
{
id: "permission-1",
method: "confirm",
@@ -369,34 +587,25 @@ describe("QuickPane", () => {
message: "Approval needed",
},
];
return result;
return snapshot;
});
render(<QuickPane />);
await preparedTrigger();
const input = screen.getByRole("textbox", { name: "Request" });
await ask("First request");
expect(await screen.findByText("First answer")).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
await ask("Active request");
await ask("Inspect");
expect(
await screen.findByRole("dialog", { name: "Review request" }),
).toBeInTheDocument();
expect(screen.getByText("Partial answer")).toBeInTheDocument();
failed = true;
expect(
await screen.findByText("Runtime disconnected.", {}, { timeout: 2_000 }),
).toBeInTheDocument();
const history = screen.getByRole("combobox", { name: "Answer history" });
await waitFor(() => expect(history).toBeEnabled());
await userEvent.selectOptions(history, "runtime-1");
const exchange = screen.getByRole("region", { name: "Quick exchange" });
expect(within(exchange).getByText("First answer")).toBeInTheDocument();
expect(within(exchange).getByRole("alert")).toHaveTextContent(
"Runtime disconnected.",
);
expect(
within(exchange).getByRole("dialog", { name: "Review request" }),
screen.getByRole("dialog", { name: "Review request" }),
).toBeInTheDocument();
expect(screen.getByText("Partial answer")).toBeInTheDocument();
});
it("renders sanitized GFM Markdown and reports active tools", async () => {
@@ -482,20 +691,17 @@ describe("QuickPane", () => {
await ask("Change files");
current = runtimeSnapshot(defaultModels, "[ESCAL", false);
const history = await screen.findByRole(
"combobox",
{ name: "Answer history" },
await waitFor(
() =>
expect(
screen.getByRole("textbox", { name: "Request" }),
).not.toHaveAttribute("readonly"),
{ timeout: 2_000 },
);
const response = screen.getByRole("region", { name: "Assistant response" });
expect(response).not.toHaveTextContent("[ESCAL");
await userEvent.selectOptions(history, "runtime-1");
expect(response).not.toHaveTextContent("[ESCAL");
expect(
within(
screen.getByRole("region", { name: "Quick exchange" }),
).queryByText("[ESCAL"),
screen.queryByRole("combobox", { name: "Answer history" }),
).toBeNull();
});
@@ -522,7 +728,7 @@ describe("QuickPane", () => {
expect(tracker.runtimeCount()).toBe(1);
});
it("routes Close and pane-level Escape through native dismissal", async () => {
it("routes Close and pane-level Escape only through native dismissal", async () => {
bridgeMock([[]]);
render(<QuickPane />);
await screen.findByText("No models are currently available.");
@@ -537,39 +743,94 @@ describe("QuickPane", () => {
),
).toHaveLength(2),
);
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
});
it("retries catalog preparation after an error", async () => {
let attempts = 0;
it("retries startup settings loading from the visible error action", async () => {
let loadAttempts = 0;
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "create_quick_runtime") {
attempts += 1;
if (command === "load_ui_state") {
loadAttempts += 1;
return loadAttempts === 1
? Promise.reject(new Error("state unavailable"))
: Promise.resolve(uiState);
}
if (command === "create_quick_runtime")
return Promise.resolve({
runtime: {
agentId: `agent-${attempts}`,
runtimeId: `runtime-${attempts}`,
},
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
}
if (command === "get_session_runtime_snapshot") {
if (attempts === 1) return Promise.reject(new Error("offline"));
if (command === "get_session_runtime_snapshot")
return Promise.resolve(runtimeSnapshot(defaultModels));
}
return Promise.resolve(undefined);
});
render(<QuickPane />);
expect(await screen.findByRole("alert")).toHaveTextContent(
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Could not load settings: Error: state unavailable",
);
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
expect(await preparedTrigger()).toBeInTheDocument();
expect(loadAttempts).toBe(2);
expect(screen.queryByText(/Could not load settings/)).toBeNull();
});
it("retries catalog preparation visibly after failed-runtime cleanup", async () => {
let attempts = 0;
mocks.invoke.mockImplementation(
(command: string, payload?: { runtimeId?: string }) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "create_quick_runtime") {
attempts += 1;
return Promise.resolve({
runtime: {
agentId: `agent-${attempts}`,
runtimeId: `runtime-${attempts}`,
},
});
}
if (command === "get_session_runtime_snapshot") {
if (payload?.runtimeId === "runtime-1")
return Promise.reject(new Error("offline"));
return Promise.resolve(runtimeSnapshot(defaultModels));
}
return Promise.resolve(undefined);
},
);
render(<QuickPane />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Could not prepare model catalog: Error: offline",
);
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(within(alert).getByRole("button", { name: "Retry" })).toBeVisible();
expect(screen.getByRole("button", { name: /model/i })).toBeDisabled();
fireEvent.change(screen.getByRole("textbox", { name: "Request" }), {
target: { value: "Blocked until ready" },
});
expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled();
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
expect(await preparedTrigger()).toBeInTheDocument();
expect(screen.queryByText(/Could not prepare model catalog/)).toBeNull();
const closeIndex = mocks.invoke.mock.calls.findIndex(
([command, payload]) =>
command === "close_quick_runtime" && payload?.runtimeId === "runtime-1",
);
const retryCreateIndex = mocks.invoke.mock.calls.findIndex(
([command], index) =>
command === "create_quick_runtime" && index > closeIndex,
);
expect(closeIndex).toBeGreaterThan(-1);
expect(retryCreateIndex).toBeGreaterThan(closeIndex);
});
});
describe("QuickPane fresh runtime catalog", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
mocks.listen.mockResolvedValue(vi.fn());
mocks.onFocusChanged.mockImplementation((handler) => {
@@ -613,6 +874,40 @@ describe("QuickPane fresh runtime catalog", () => {
});
});
it("preserves idle runtime, transcript, and catalog when hidden and reopened within three minutes", async () => {
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const tracker = bridgeMock([defaultModels], () =>
snapshotWithTranscript([
{ role: "user", content: "Question" },
{ role: "assistant", content: "Remembered answer" },
]),
);
render(<QuickPane />);
const trigger = await preparedTrigger();
await ask("Question");
expect(await screen.findByText("Remembered answer")).toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Request" }),
).not.toHaveAttribute("readonly"),
);
await userEvent.click(
screen.getByRole("button", { name: "Close Quick Pane" }),
);
await act(async () => mocks.focusHandler?.({ payload: false }));
now += 180_000;
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(tracker.runtimeCount()).toBe(1);
expect(trigger).toBeInTheDocument();
expect(screen.getByText("Remembered answer")).toBeInTheDocument();
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
});
it.each([
{
action: "Close",
@@ -629,7 +924,7 @@ describe("QuickPane fresh runtime catalog", () => {
},
},
])(
"discards deferred catalog preparation after $action dismissal",
"retains deferred catalog preparation across $action dismissal",
async ({ dismiss }) => {
const tracker = deferredCatalogBridge();
render(<QuickPane />);
@@ -644,39 +939,23 @@ describe("QuickPane fresh runtime catalog", () => {
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"),
);
await act(async () => mocks.focusHandler?.({ payload: false }));
await act(async () =>
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
}),
);
expect(
screen.queryByRole("button", { name: /provider · Stale/i }),
).toBeNull();
await screen.findByRole("button", { name: /provider · Stale/i }),
).toBeInTheDocument();
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(
await screen.findByRole("button", { name: /provider · Fresh/i }),
).toBeInTheDocument();
expect(tracker.runtimeCount()).toBe(2);
await ask();
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-2",
provider: "provider",
modelId: "fresh/model",
}),
);
expect(mocks.invoke).not.toHaveBeenCalledWith(
"set_model",
expect.objectContaining({ agentId: "agent-1" }),
);
expect(tracker.runtimeCount()).toBe(1);
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
},
);
it("discards deferred catalog preparation after idle focus loss", async () => {
it("retains deferred catalog preparation across idle focus loss", async () => {
const tracker = deferredCatalogBridge();
render(<QuickPane />);
await waitFor(() =>
@@ -687,86 +966,22 @@ describe("QuickPane fresh runtime catalog", () => {
);
await act(async () => mocks.focusHandler?.({ payload: false }));
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
await act(async () =>
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
}),
);
expect(
screen.queryByRole("button", { name: /provider · Stale/i }),
).toBeNull();
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(
await screen.findByRole("button", { name: /provider · Fresh/i }),
await screen.findByRole("button", { name: /provider · Stale/i }),
).toBeInTheDocument();
expect(tracker.runtimeCount()).toBe(2);
await ask();
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-2",
provider: "provider",
modelId: "fresh/model",
}),
);
expect(mocks.invoke).not.toHaveBeenCalledWith(
"set_model",
expect.objectContaining({ agentId: "agent-1" }),
);
expect(tracker.runtimeCount()).toBe(1);
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
});
it("retains a prior runtime when close fails and retries before replacement", async () => {
let runtimeCount = 0;
let closeAttempts = 0;
const activeRuntimeIds = new Set<string>();
let maximumActive = 0;
mocks.invoke.mockImplementation(
(command: string, payload?: { runtimeId?: string }) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "create_quick_runtime") {
runtimeCount += 1;
const runtimeId = `runtime-${runtimeCount}`;
activeRuntimeIds.add(runtimeId);
maximumActive = Math.max(maximumActive, activeRuntimeIds.size);
return Promise.resolve({
runtime: { agentId: `agent-${runtimeCount}`, runtimeId },
});
}
if (command === "get_session_runtime_snapshot")
return Promise.resolve(runtimeSnapshot(defaultModels));
if (command === "close_quick_runtime") {
closeAttempts += 1;
if (closeAttempts === 1)
return Promise.reject(new Error("close unavailable"));
activeRuntimeIds.delete(payload?.runtimeId ?? "");
return Promise.resolve(undefined);
}
return Promise.resolve(undefined);
},
);
render(<QuickPane />);
await preparedTrigger();
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not prepare model catalog: Error: close unavailable",
);
expect(runtimeCount).toBe(1);
expect(activeRuntimeIds).toEqual(new Set(["runtime-1"]));
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(runtimeCount).toBe(2));
expect(closeAttempts).toBe(2);
expect(activeRuntimeIds).toEqual(new Set(["runtime-2"]));
expect(maximumActive).toBe(1);
expect(screen.queryByText(/close unavailable/)).toBeNull();
});
it("queues a created stale runtime after close failure and drains it before preparing again", async () => {
it("retries stale-created-runtime cleanup before stale chat renewal", async () => {
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const firstSnapshot = deferred<RuntimeSnapshot>();
let runtimeCount = 0;
let firstCloseAttempts = 0;
@@ -816,6 +1031,7 @@ describe("QuickPane fresh runtime catalog", () => {
);
expect(activeRuntimeIds).toEqual(new Set(["runtime-1", "runtime-2"]));
now += 180_001;
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(runtimeCount).toBe(3));
expect(firstCloseAttempts).toBe(2);
@@ -830,46 +1046,132 @@ describe("QuickPane fresh runtime catalog", () => {
).toHaveLength(1);
});
it("recycles idle prepared runtime on focus and exposes new catalog", async () => {
const tracker = bridgeMock([
defaultModels,
[{ provider: "provider", id: "new/model", name: "Fresh" }],
]);
render(<QuickPane />);
await preparedTrigger();
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
expect(
await screen.findByRole("button", { name: /provider · Fresh/i }),
).toBeInTheDocument();
});
it("prepares a fresh runtime before a repeated request without reopen", async () => {
const tracker = bridgeMock(
[defaultModels, [{ provider: "provider", id: "second/model" }]],
(index) => runtimeSnapshot(defaultModels, `Done ${index + 1}`, false),
it("Ctrl+N resets an idle chat and retries cleanup before replacement", async () => {
const tracker = bridgeMock([defaultModels, defaultModels], () =>
runtimeSnapshot(defaultModels, "Old answer", false),
);
const baseImplementation = mocks.invoke.getMockImplementation();
let closeAttempts = 0;
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "close_quick_runtime") {
closeAttempts += 1;
if (closeAttempts === 1)
return Promise.reject(new Error("close unavailable"));
}
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
await ask("First");
expect(await screen.findByText("Done 1")).toBeInTheDocument();
await ask("Old question");
expect(await screen.findByText("Old answer")).toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Request" }),
).not.toHaveAttribute("readonly"),
);
await ask("Second");
fireEvent.change(screen.getByRole("textbox", { name: "Request" }), {
target: { value: "draft" },
});
const event = new KeyboardEvent("keydown", {
key: "n",
ctrlKey: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
expect(closeAttempts).toBe(2);
expect(screen.queryByText("Old answer")).toBeNull();
expect(screen.getByRole("textbox", { name: "Request" })).toHaveValue("");
expect(mocks.invoke).not.toHaveBeenCalledWith("abort", expect.anything());
});
it("Ctrl+N aborts active work before closing and preparing a fresh runtime", async () => {
const tracker = bridgeMock([defaultModels, defaultModels], () =>
runtimeSnapshot(defaultModels, "Partial", true),
);
render(<QuickPane />);
await preparedTrigger();
await ask("Active question");
expect(await screen.findByText("Partial")).toBeInTheDocument();
const event = new KeyboardEvent("keydown", {
key: "N",
ctrlKey: true,
cancelable: true,
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
const abortIndex = mocks.invoke.mock.calls.findIndex(
([command]) => command === "abort",
);
const closeIndex = mocks.invoke.mock.calls.findIndex(
([command]) => command === "close_quick_runtime",
);
expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" });
expect(abortIndex).toBeLessThan(closeIndex);
expect(screen.queryByText("Partial")).toBeNull();
});
it("renews a stale idle chat when reopened after three minutes", async () => {
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const freshModels: Model[] = [
{ provider: "provider", id: "fresh/model", name: "Fresh" },
];
const tracker = bridgeMock([defaultModels, freshModels], () =>
snapshotWithTranscript([
{ role: "user", content: "Question" },
{ role: "assistant", content: "Expired answer" },
]),
);
render(<QuickPane />);
await preparedTrigger();
await ask("Question");
expect(await screen.findByText("Expired answer")).toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Request" }),
).not.toHaveAttribute("readonly"),
);
await userEvent.click(
screen.getByRole("button", { name: "Close Quick Pane" }),
);
await act(async () => mocks.focusHandler?.({ payload: false }));
now += 180_001;
await act(async () => mocks.focusHandler?.({ payload: true }));
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-2",
provider: "provider",
modelId: "second/model",
expect(screen.queryByText("Expired answer")).toBeNull();
expect(
await screen.findByRole("button", { name: /provider · Fresh/i }),
).toBeInTheDocument();
});
it("does not renew or queue renewal while request is active", async () => {
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const tracker = bridgeMock([defaultModels], () =>
runtimeSnapshot(defaultModels, "Still working", true),
);
render(<QuickPane />);
await preparedTrigger();
await ask("Long request");
expect(await screen.findByText("Still working")).toBeInTheDocument();
now += 180_001;
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(tracker.runtimeCount()).toBe(1);
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
});
+428 -240
View File
@@ -15,6 +15,7 @@ import {
import "./quick-pane.css";
import type {
Extension,
Message,
Model,
QuickPaneSettings,
RuntimeSnapshot,
@@ -22,6 +23,8 @@ import type {
} from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]";
const QUICK_PANE_ADVISORY_PREFIX = "[Quick Pane advisory mode:";
const USER_REQUEST_DELIMITER = "\n\nUser request: ";
const pendingQuickRuntimeCleanupIds = new Set<string>();
const pendingQuickRuntimeCleanupPromises = new Map<string, Promise<void>>();
@@ -32,22 +35,79 @@ const defaults: QuickPaneSettings = {
followUpProvider: "",
followUpModel: "",
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
maximumSeconds: 60,
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.",
maximumToolCalls: 6,
};
type Runtime = { agentId: string; runtimeId: string };
type PreparedRuntime = { runtime: Runtime; models: Model[] };
type HistoryEntry = { id: string; request: string; answer: string };
type TranscriptMessage = {
id: string;
role: "user" | "assistant";
markdown: string;
};
type ActivitySnapshot = {
activeTool?: string;
hasAssistantText: boolean;
};
function historyLabel(request: string) {
const concise = request.replace(/\s+/g, " ").trim();
return concise.length > 42 ? `${concise.slice(0, 39)}…` : concise;
const CHAT_RENEWAL_MS = 180_000;
function messageText(message: Message) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.filter((part) => !part.type || part.type === "text")
.map((part) => part.text ?? "")
.filter(Boolean)
.join("\n");
}
function visibleAssistantText(text: string) {
if (text && ESCALATION_MARKER.startsWith(text)) return "";
return text.startsWith(ESCALATION_MARKER)
? text.slice(ESCALATION_MARKER.length).trim()
: text;
}
function visibleUserText(
text: string,
submittedPromptRequests: ReadonlyMap<string, string>,
) {
const submittedRequest = submittedPromptRequests.get(text);
if (submittedRequest !== undefined) return submittedRequest;
return text.startsWith(QUICK_PANE_ADVISORY_PREFIX) ? "" : text;
}
function transcriptMessages(
snapshot: RuntimeSnapshot,
submittedPromptRequests: ReadonlyMap<string, string>,
): TranscriptMessage[] {
return (snapshot.transcript?.data?.messages ?? []).flatMap(
(message, index) => {
if (message.role !== "user" && message.role !== "assistant") return [];
const text = messageText(message);
const markdown =
message.role === "assistant"
? visibleAssistantText(text)
: visibleUserText(text, submittedPromptRequests);
if (!markdown) return [];
return [
{
id: `${index}-${message.role}`,
role: message.role,
markdown,
},
];
},
);
}
function snapshotIsActive(snapshot: RuntimeSnapshot) {
return (
snapshot.state?.data?.isStreaming === true ||
snapshot.runtime.state === "streaming" ||
snapshot.runtime.queueCount > 0
);
}
function settingsError(settings: QuickPaneSettings) {
@@ -59,12 +119,6 @@ function settingsError(settings: QuickPaneSettings) {
return "Follow-up provider and model are required.";
if (!settings.instructions.trim())
return "Supplemental instructions are required.";
if (
!Number.isInteger(settings.maximumSeconds) ||
settings.maximumSeconds < 1 ||
settings.maximumSeconds > 600
)
return "Seconds must be an integer from 1 to 600.";
if (
!Number.isInteger(settings.maximumToolCalls) ||
settings.maximumToolCalls < 1 ||
@@ -73,17 +127,29 @@ function settingsError(settings: QuickPaneSettings) {
return "Tool calls must be an integer from 1 to 64.";
}
function assistantText(snapshot: RuntimeSnapshot) {
function currentTurnAssistantText(
snapshot: RuntimeSnapshot,
submittedPromptRequests: ReadonlyMap<string, string>,
) {
const messages = snapshot.transcript?.data?.messages ?? [];
const message = [...messages]
let submittedUserIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (
message.role === "user" &&
submittedPromptRequests.has(messageText(message))
) {
submittedUserIndex = index;
break;
}
}
if (submittedUserIndex === -1) return "";
const message = messages
.slice(submittedUserIndex + 1)
.reverse()
.find((candidate) => candidate.role === "assistant");
if (!message) return "";
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.map((part) => part.text ?? "")
.filter(Boolean)
.join("\n");
return message ? messageText(message) : "";
}
function toolActivityLabel(tool: string) {
@@ -108,7 +174,6 @@ export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [request, setRequest] = useState("");
const [currentRequest, setCurrentRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
@@ -125,10 +190,9 @@ export default function QuickPane() {
const [catalogError, setCatalogError] = useState<string>();
const [catalogNotice, setCatalogNotice] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [responseComplete, setResponseComplete] = useState(false);
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
const [activitySnapshot, setActivitySnapshot] = useState<ActivitySnapshot>({
@@ -145,25 +209,28 @@ export default function QuickPane() {
const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined
>(undefined);
const submittedPromptRequestsRef = useRef(new Map<string, string>());
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
const completedRuntimeRef = useRef<string | undefined>(undefined);
const completionHandledRef = useRef(false);
const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const chatCreatedAtRef = useRef<number | undefined>(undefined);
const lastUserQuestionAtRef = useRef<number | undefined>(undefined);
const lastCompletedAnswerAtRef = useRef<number | undefined>(undefined);
const chatGenerationRef = useRef(0);
const resetStartingRef = useRef(false);
const preparationGenerationRef = useRef(0);
const preparationLockRef = useRef(false);
const catalogReadyRef = useRef(false);
const selectedModelRef = useRef(selectedModel);
const inputRef = useRef<HTMLTextAreaElement>(null);
const paneRef = useRef<HTMLElement>(null);
const exchangeRef = useRef<HTMLElement>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const resizeFrameRef = useRef<number | undefined>(undefined);
const requestedPaneHeightRef = useRef<number | undefined>(undefined);
const invalid = useMemo(() => settingsError(settings), [settings]);
const runActive = submissionStarting || requestActive;
const viewingHistory = !!selectedHistoryId && !runActive;
const displayedEntry = viewingHistory
? history.find((entry) => entry.id === selectedHistoryId)
: undefined;
const displayedRequest = displayedEntry?.request ?? currentRequest;
const displayedAnswer = displayedEntry?.answer ?? answer;
const displayedAnswerComplete =
!viewingHistory && !!runtime && completedRuntimeId === runtime.runtimeId;
const activityPhase = submissionStarting
? "Starting"
: runActive && activitySnapshot.activeTool
@@ -237,6 +304,7 @@ export default function QuickPane() {
let created: Runtime | undefined;
let retained = false;
let preparationFailure: unknown;
let cleanupFailure: unknown;
try {
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
@@ -256,6 +324,7 @@ export default function QuickPane() {
runtimeRef.current = created;
preparedRuntimeRef.current = { runtime: created, models: freshModels };
chatCreatedAtRef.current = Date.now();
setRuntime(created);
setModels(freshModels);
retained = true;
@@ -279,79 +348,48 @@ export default function QuickPane() {
return true;
} catch (reason) {
preparationFailure = reason;
if (generation === preparationGenerationRef.current) {
catalogReadyRef.current = false;
preparedRuntimeRef.current = undefined;
setCatalogStatus("error");
setCatalogError(`Could not prepare model catalog: ${String(reason)}`);
}
return false;
} finally {
if (created && !retained) {
try {
await closeRuntime(created.runtimeId);
} catch (cleanupReason) {
if (generation === preparationGenerationRef.current) {
catalogReadyRef.current = false;
setCatalogStatus("error");
setCatalogError(
preparationFailure
? `Could not prepare model catalog: ${String(preparationFailure)}; cleanup failed: ${String(cleanupReason)}`
: `Could not clean up stale quick runtime: ${String(cleanupReason)}`,
);
} else if (mountedRef.current) {
setCatalogError(
`Could not clean up stale quick runtime: ${String(cleanupReason)}`,
);
}
} catch (reason) {
cleanupFailure = reason;
}
}
if (
generation === preparationGenerationRef.current &&
(preparationFailure || cleanupFailure)
) {
catalogReadyRef.current = false;
preparedRuntimeRef.current = undefined;
setCatalogStatus("error");
setCatalogError(
preparationFailure
? `Could not prepare model catalog: ${String(preparationFailure)}${cleanupFailure ? `; cleanup failed: ${String(cleanupFailure)}` : ""}`
: `Could not clean up stale quick runtime: ${String(cleanupFailure)}`,
);
} else if (cleanupFailure && mountedRef.current) {
setCatalogError(
`Could not clean up stale quick runtime: ${String(cleanupFailure)}`,
);
}
}
},
[clearRuntimeIfCurrent, closeRuntime],
);
const invalidateIdlePreparation = useCallback(() => {
if (requestActiveRef.current || submissionStartingRef.current) return;
preparationGenerationRef.current += 1;
preparationLockRef.current = false;
const loadSettingsAndPrepare = useCallback(async () => {
setSettingsLoaded(false);
setNotice("Loading Quick Pane settings…");
setError(undefined);
catalogReadyRef.current = false;
}, []);
const closeIdleRuntime = useCallback(async () => {
const current = runtimeRef.current;
if (!current || requestActiveRef.current || submissionStartingRef.current)
return true;
setCatalogStatus("loading");
setCatalogError(undefined);
try {
await closeRuntime(current.runtimeId);
clearRuntimeIfCurrent(current.runtimeId);
return true;
} catch (reason) {
setCatalogStatus("error");
setCatalogError(`Could not close Quick runtime: ${String(reason)}`);
return false;
}
}, [clearRuntimeIfCurrent, closeRuntime]);
const dismissPane = useCallback(async () => {
invalidateIdlePreparation();
if (await closeIdleRuntime()) await invoke("dismiss_quick_pane");
}, [closeIdleRuntime, invalidateIdlePreparation]);
useEffect(() => {
let active = true;
mountedRef.current = true;
void (async () => {
let nextSettings: QuickPaneSettings;
try {
const state = await invoke<UiState>("load_ui_state");
if (!active) return;
nextSettings = { ...defaults, ...state.quickPane };
} catch (reason) {
if (active) setError(`Could not load settings: ${String(reason)}`);
return;
}
const state = await invoke<UiState>("load_ui_state");
if (!mountedRef.current) return false;
const nextSettings = { ...defaults, ...state.quickPane };
settingsRef.current = nextSettings;
setSettings(nextSettings);
setSettingsLoaded(true);
@@ -362,21 +400,164 @@ export default function QuickPane() {
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
setNotice("");
if (active) await prepareRuntime(configuredModel);
})();
return await prepareRuntime(configuredModel);
} catch (reason) {
if (mountedRef.current) {
setNotice("");
setError(`Could not load settings: ${String(reason)}`);
}
return false;
}
}, [prepareRuntime]);
const dismissPane = useCallback(async () => {
await invoke("dismiss_quick_pane");
}, []);
const clearChatState = useCallback((clearDraft: boolean) => {
chatGenerationRef.current += 1;
chatCreatedAtRef.current = undefined;
lastUserQuestionAtRef.current = undefined;
lastCompletedAnswerAtRef.current = undefined;
submittedRequestRef.current = undefined;
submittedPromptRequestsRef.current.clear();
promptSubmittedRuntimeRef.current = undefined;
completionHandledRef.current = false;
runStartedAtRef.current = undefined;
requestActiveRef.current = false;
submissionStartingRef.current = false;
setRequestActive(false);
setSubmissionStarting(false);
setMessages([]);
setAnswer("");
setResponseComplete(false);
setEscalation(undefined);
setExtension(undefined);
setActivitySnapshot({ hasAssistantText: false });
setElapsedSeconds(0);
setNotice("");
setError(undefined);
setCatalogStatus("loading");
setCatalogError(undefined);
setCatalogNotice(undefined);
setModels([]);
if (clearDraft) setRequest("");
}, []);
const replaceChat = useCallback(
async (clearDraft: boolean, abortActive: boolean) => {
if (resetStartingRef.current) return false;
resetStartingRef.current = true;
preparationGenerationRef.current += 1;
preparationLockRef.current = false;
catalogReadyRef.current = false;
const current = runtimeRef.current;
const wasActive =
requestActiveRef.current || submissionStartingRef.current;
try {
if (abortActive && wasActive && current) {
await invoke("abort", { agentId: current.agentId }).catch(
() => undefined,
);
}
clearChatState(clearDraft);
if (current) {
try {
await closeRuntime(current.runtimeId);
clearRuntimeIfCurrent(current.runtimeId);
} catch {
// prepareRuntime retries every queued cleanup before replacement.
}
}
return await prepareRuntime(selectedModelRef.current, true);
} finally {
resetStartingRef.current = false;
window.requestAnimationFrame(focusInput);
}
},
[clearChatState, clearRuntimeIfCurrent, closeRuntime, prepareRuntime],
);
const renewChatOnFocus = useCallback(async () => {
if (requestActiveRef.current || submissionStartingRef.current) return;
if (!runtimeRef.current) {
clearChatState(false);
await prepareRuntime();
return;
}
const lastActivity = Math.max(
lastUserQuestionAtRef.current ?? 0,
lastCompletedAnswerAtRef.current ?? 0,
chatCreatedAtRef.current ?? 0,
);
if (Date.now() - lastActivity > CHAT_RENEWAL_MS)
await replaceChat(false, false);
}, [clearChatState, prepareRuntime, replaceChat]);
useEffect(() => {
mountedRef.current = true;
void loadSettingsAndPrepare();
return () => {
active = false;
mountedRef.current = false;
preparationGenerationRef.current += 1;
const current = runtimeRef.current;
if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
};
}, [closeRuntime, prepareRuntime]);
}, [closeRuntime, loadSettingsAndPrepare]);
useEffect(() => {
if (settingsLoaded) focusInput();
}, [settingsLoaded]);
useEffect(() => {
const pane = paneRef.current;
const exchange = exchangeRef.current;
if (!pane || !exchange) return;
const resizeToContent = () => {
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
resizeFrameRef.current = window.requestAnimationFrame(() => {
const visibleChildren = [...exchange.children].filter(
(child) => !child.classList.contains("sr-only"),
);
const childHeight = visibleChildren.reduce(
(total, child) =>
total + Math.max(child.scrollHeight, child.getBoundingClientRect().height),
0,
);
const styles = window.getComputedStyle(exchange);
const contentHeight =
childHeight +
Math.max(0, visibleChildren.length - 1) *
(Number.parseFloat(styles.rowGap || styles.gap || "0") || 0) +
(Number.parseFloat(styles.paddingTop) || 0) +
(Number.parseFloat(styles.paddingBottom) || 0);
const height = Math.min(
520,
Math.max(
360,
Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight),
),
);
if (height === requestedPaneHeightRef.current) return;
requestedPaneHeightRef.current = height;
void invoke("resize_quick_pane", { height }).catch(() => undefined);
});
};
if (typeof ResizeObserver === "undefined") {
resizeToContent();
return () => window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
}
const observer = new ResizeObserver(resizeToContent);
observer.observe(pane);
observer.observe(exchange);
if (transcriptRef.current) observer.observe(transcriptRef.current);
resizeToContent();
return () => {
observer.disconnect();
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
};
}, [activity, catalogError, catalogNotice, error, escalation, messages, notice, responseComplete]);
useEffect(() => {
if (!runActive) return;
const updateElapsed = () =>
@@ -412,6 +593,11 @@ export default function QuickPane() {
},
);
const onKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key.toLowerCase() === "n") {
event.preventDefault();
void replaceChat(true, true);
return;
}
if (event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target;
const selector =
@@ -425,91 +611,66 @@ export default function QuickPane() {
};
window.addEventListener("keydown", onKeyDown);
const focusListener = paneWindow.onFocusChanged((event) => {
if (event.payload) {
focusInput();
if (!requestActiveRef.current && !submissionStartingRef.current)
void prepareRuntime();
} else if (!requestActiveRef.current && !submissionStartingRef.current) {
invalidateIdlePreparation();
void closeIdleRuntime();
}
if (!event.payload) return;
focusInput();
void renewChatOnFocus();
});
return () => {
window.removeEventListener("keydown", onKeyDown);
void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten());
};
}, [
closeIdleRuntime,
dismissPane,
invalidateIdlePreparation,
prepareRuntime,
]);
}, [dismissPane, prepareRuntime, renewChatOnFocus, replaceChat]);
useEffect(() => {
if (!runtime || !requestActive) return;
let active = true;
const chatGeneration = chatGenerationRef.current;
const refresh = async () => {
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{
runtimeId: runtime.runtimeId,
},
{ runtimeId: runtime.runtimeId },
);
if (!active) return;
const text = assistantText(snapshot);
const responseComplete =
snapshot.state?.data?.isStreaming === false &&
if (!active || chatGeneration !== chatGenerationRef.current) return;
const text = currentTurnAssistantText(
snapshot,
submittedPromptRequestsRef.current,
);
const result = visibleAssistantText(text);
const complete =
!snapshotIsActive(snapshot) &&
promptSubmittedRuntimeRef.current === runtime.runtimeId;
const escalated = text.startsWith(ESCALATION_MARKER);
const awaitingEscalationMarker =
!!text && ESCALATION_MARKER.startsWith(text);
const result = awaitingEscalationMarker
? ""
: escalated
? text.slice(ESCALATION_MARKER.length).trim()
: text;
setMessages(
transcriptMessages(snapshot, submittedPromptRequestsRef.current),
);
setActivitySnapshot({
activeTool: snapshot.runtime.activeTool,
hasAssistantText: !!text,
});
if (awaitingEscalationMarker) {
setAnswer("");
} else if (text) {
if (promptSubmittedRuntimeRef.current === runtime.runtimeId)
setAnswer(result);
}
if (escalated && responseComplete) {
setEscalation(result || "Quick agent requested a full session.");
}
setExtension(snapshot.extensions[0]);
if (
responseComplete &&
completedRuntimeRef.current !== runtime.runtimeId
complete &&
!completionHandledRef.current &&
requestActiveRef.current
) {
completedRuntimeRef.current = runtime.runtimeId;
completionHandledRef.current = true;
lastCompletedAnswerAtRef.current = Date.now();
requestActiveRef.current = false;
setRequestActive(false);
setCompletedRuntimeId(runtime.runtimeId);
setResponseComplete(true);
setNotice("");
const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) {
if (submitted?.runtimeId === runtime.runtimeId)
setRequest((current) =>
current === submitted.request ? "" : current,
);
setHistory((current) =>
[
{
id: runtime.runtimeId,
request: submitted.request,
answer: result,
},
...current.filter((entry) => entry.id !== runtime.runtimeId),
].slice(0, 5),
);
}
if (text.startsWith(ESCALATION_MARKER))
setEscalation(result || "Quick agent requested a full session.");
window.requestAnimationFrame(focusInput);
}
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
requestActiveRef.current = false;
@@ -519,7 +680,7 @@ export default function QuickPane() {
);
}
} catch (reason) {
if (active)
if (active && chatGeneration === chatGenerationRef.current)
setError(`Could not refresh quick result: ${String(reason)}`);
}
};
@@ -531,18 +692,6 @@ export default function QuickPane() {
};
}, [requestActive, runtime]);
useEffect(() => {
if (!runtime || !requestActive) return;
const timeout = window.setTimeout(() => {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
requestActiveRef.current = false;
setRequestActive(false);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [requestActive, runtime, settings.maximumSeconds]);
async function run() {
if (submissionStartingRef.current || requestActiveRef.current) return;
@@ -555,6 +704,7 @@ export default function QuickPane() {
preparationLockRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
const chatGeneration = chatGenerationRef.current;
try {
if (!preparedRuntimeRef.current) {
const prepared = await prepareRuntime(selectedModelRef.current, true);
@@ -575,23 +725,23 @@ export default function QuickPane() {
throw new Error("Selected model is not in prepared runtime catalog.");
const submittedRuntime = prepared.runtime;
preparedRuntimeRef.current = undefined;
if (chatGeneration !== chatGenerationRef.current) return;
submittedRequestRef.current = {
runtimeId: submittedRuntime.runtimeId,
request: submittedRequest,
};
promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
setCompletedRuntimeId(undefined);
setError(undefined);
completionHandledRef.current = false;
setAnswer("");
setResponseComplete(false);
setError(undefined);
setEscalation(undefined);
setExtension(undefined);
setActivitySnapshot({ hasAssistantText: false });
setNotice("");
runStartedAtRef.current = Date.now();
const submittedAt = Date.now();
lastUserQuestionAtRef.current = submittedAt;
runStartedAtRef.current = submittedAt;
setElapsedSeconds(0);
requestActiveRef.current = true;
setRequestActive(true);
@@ -601,26 +751,34 @@ export default function QuickPane() {
provider: submittedModel.provider,
modelId: submittedModel.modelId,
});
if (chatGeneration !== chatGenerationRef.current) return;
await invoke("set_thinking_level", {
agentId: submittedRuntime.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: ${submittedRequest}`;
if (chatGeneration !== chatGenerationRef.current) return;
const guardrails = `[Quick Pane advisory mode: use at most ${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 tool calls, start final response with [ESCALATE] and explain why.]\n${settings.instructions}${USER_REQUEST_DELIMITER}${submittedRequest}`;
submittedPromptRequestsRef.current.set(guardrails, submittedRequest);
await invoke("submit_prompt", {
agentId: submittedRuntime.agentId,
message: guardrails,
});
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
if (chatGeneration === chatGenerationRef.current)
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
} catch (reason) {
requestActiveRef.current = false;
setRequestActive(false);
setError(`Could not start quick request: ${String(reason)}`);
if (chatGeneration === chatGenerationRef.current) {
requestActiveRef.current = false;
setRequestActive(false);
setError(`Could not start quick request: ${String(reason)}`);
}
} finally {
preparationLockRef.current = false;
submissionStartingRef.current = false;
setSubmissionStarting(false);
focusInput();
window.requestAnimationFrame(focusInput);
if (chatGeneration === chatGenerationRef.current) {
preparationLockRef.current = false;
submissionStartingRef.current = false;
setSubmissionStarting(false);
focusInput();
window.requestAnimationFrame(focusInput);
}
}
}
@@ -638,9 +796,18 @@ export default function QuickPane() {
}
}
const handoffRequest =
request.trim() || submittedRequestRef.current?.request || "";
const canContinue = !invalid && !!handoffRequest;
const completedAssistantAnswer =
responseComplete && messages.some((message) => message.role === "assistant");
const transcriptHandoff = messages
.map((message) =>
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
)
.join("\n\n");
async function continueFull() {
const handoffRequest =
request.trim() || submittedRequestRef.current?.request || currentRequest;
if (invalid || !handoffRequest)
return setError(invalid ?? "Enter a request.");
try {
@@ -657,7 +824,7 @@ export default function QuickPane() {
});
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
message: `${handoffRequest}\n\nQuick Pane handoff: ${escalation ?? "Continue this chat in a full session."}\n\nQuick Pane transcript:\n${transcriptHandoff || answer}`,
});
setNotice("Full session started with follow-up model.");
} catch (reason) {
@@ -666,9 +833,14 @@ export default function QuickPane() {
}
return (
<main className="quick-pane">
<main className="quick-pane" ref={paneRef}>
<header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls">
{catalogStatus === "loading" && (
<span className="quick-pane-model-loading" role="status">
Loading models…
</span>
)}
<ModelSelector
className="quick-pane-model-selector"
placement="down"
@@ -704,29 +876,6 @@ export default function QuickPane() {
</label>
</div>
<div className="quick-pane-header-actions">
{history.length > 0 && (
<label className="quick-pane-field quick-pane-history">
<span className="full-label">History</span>
<span className="compact-label" aria-hidden="true">
H
</span>
<select
aria-label="Answer history"
value={viewingHistory ? selectedHistoryId : ""}
disabled={runActive}
onChange={(event) =>
setSelectedHistoryId(event.currentTarget.value)
}
>
<option value="">Current</option>
{history.map((entry) => (
<option key={entry.id} value={entry.id}>
{historyLabel(entry.request)}
</option>
))}
</select>
</label>
)}
<button
className="quick-pane-close"
type="button"
@@ -786,26 +935,43 @@ export default function QuickPane() {
</span>
</div>
<section className="quick-pane-exchange" aria-label="Quick exchange">
{displayedRequest && (
<div className="quick-pane-user-row">
<article className="quick-pane-user-message">
<span className="message-author">You</span>
<p>{displayedRequest}</p>
</article>
</div>
)}
{(viewingHistory
? !!displayedAnswer
: runActive || !!displayedAnswer || displayedAnswerComplete) && (
<article
className="quick-pane-assistant-message"
<section
className="quick-pane-exchange"
aria-label="Quick exchange"
ref={exchangeRef}
>
{(messages.length > 0 || runActive || responseComplete) && (
<div
className="quick-pane-transcript"
ref={transcriptRef}
role="region"
aria-label="Assistant response"
aria-live={viewingHistory ? "off" : "polite"}
aria-busy={!viewingHistory && runActive}
aria-live="polite"
aria-busy={runActive}
>
{!viewingHistory && activity && (
{messages.map((message) =>
message.role === "user" ? (
<div className="quick-pane-user-row" key={message.id}>
<article className="quick-pane-user-message">
<span className="message-author">You</span>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.markdown}
</ReactMarkdown>
</article>
</div>
) : (
<article
className="quick-pane-assistant-message"
key={message.id}
>
<span className="message-author">Assistant</span>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.markdown}
</ReactMarkdown>
</article>
),
)}
{activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
@@ -815,43 +981,59 @@ export default function QuickPane() {
<span>{activity}</span>
</div>
)}
{displayedAnswer && (
<>
<span className="message-author">Assistant</span>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayedAnswer}
</ReactMarkdown>
</>
)}
{displayedAnswerComplete && (
{responseComplete && (
<span className="sr-only">Response complete.</span>
)}
</article>
</div>
)}
<span
className="quick-pane-activity-announcement sr-only"
aria-live="polite"
aria-atomic="true"
>
{viewingHistory ? "" : activityPhase}
{activityPhase}
</span>
{completedAssistantAnswer && !escalation && canContinue && (
<aside className="quick-pane-notice continue" role="status">
<div>
<strong>Continue in full session</strong>
<p>Open this chat in a normal session when more work is needed.</p>
</div>
<button type="button" onClick={continueFull}>
Continue in full session
</button>
</aside>
)}
{escalation && (
<aside className="quick-pane-notice escalation" role="status">
<div>
<strong>Continue in full session</strong>
<p>{escalation}</p>
</div>
<button type="button" onClick={continueFull}>
Continue
</button>
{canContinue && (
<button type="button" onClick={continueFull}>
Continue
</button>
)}
</aside>
)}
{error && (
<aside className="quick-pane-notice error" role="alert">
<p>{error}</p>
{!!runtime && !escalation && (
<button type="button" onClick={continueFull}>
Continue
{settingsLoaded ? (
!!runtime &&
!escalation &&
canContinue && (
<button type="button" onClick={continueFull}>
Continue
</button>
)
) : (
<button
type="button"
onClick={() => void loadSettingsAndPrepare()}
>
Retry
</button>
)}
</aside>
@@ -859,6 +1041,12 @@ export default function QuickPane() {
{catalogError && (
<aside className="quick-pane-notice error" role="alert">
<p>{catalogError}</p>
<button
type="button"
onClick={() => void prepareRuntime(selectedModelRef.current)}
>
Retry
</button>
</aside>
)}
{notice && (
+15 -9
View File
@@ -7,6 +7,7 @@ body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
@@ -22,7 +23,7 @@ body {
flex-direction: column;
gap: 9px;
height: 100vh;
overflow: visible;
overflow: hidden;
padding: 10px;
color: #e6e3df;
font:
@@ -83,6 +84,13 @@ body {
text-transform: uppercase;
}
.quick-pane-model-loading {
flex: 0 0 auto;
color: #f1e4d1;
font-size: 11px;
font-weight: 750;
}
.quick-pane .model-trigger {
height: 27px;
border: 0;
@@ -127,10 +135,6 @@ body {
flex: 0 1 122px;
}
.quick-pane-history {
flex: 0 1 205px;
}
.quick-pane .compact-label {
display: none;
}
@@ -197,6 +201,12 @@ body {
scroll-behavior: smooth;
}
.quick-pane-transcript {
display: flex;
flex-direction: column;
gap: 9px;
}
.quick-pane-user-row {
display: flex;
flex: 0 0 auto;
@@ -504,10 +514,6 @@ body {
flex-basis: 74px;
}
.quick-pane-history {
max-width: 78px;
}
.quick-pane-field {
gap: 2px;
}
-1
View File
@@ -228,7 +228,6 @@ export type QuickPaneSettings = {
followUpProvider: string;
followUpModel: string;
instructions: string;
maximumSeconds: number;
maximumToolCalls: number;
};