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
+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;
}