feat(quick-pane): add follow-up chat renewal
This commit is contained in:
+34
-2
@@ -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()];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -228,7 +228,6 @@ export type QuickPaneSettings = {
|
||||
followUpProvider: string;
|
||||
followUpModel: string;
|
||||
instructions: string;
|
||||
maximumSeconds: number;
|
||||
maximumToolCalls: number;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user