feat(quick-pane): add compact runtime controls

This commit is contained in:
2026-08-27 23:37:47 +02:00
parent e869a0bfcb
commit 5e9074605d
10 changed files with 1090 additions and 227 deletions
+37 -10
View File
@@ -51,26 +51,30 @@ test("main Settings uses shared catalog selectors with provider derived from mod
assert.doesNotMatch(pane, /showSettings/); assert.doesNotMatch(pane, /showSettings/);
assert.doesNotMatch(pane, /Save Quick Pane settings/); assert.doesNotMatch(pane, /Save Quick Pane settings/);
assert.match(pane, /!snapshotIsActive\(snapshot\)/); assert.match(pane, /!snapshotIsActive\(snapshot\)/);
assert.match(pane, /current === submitted\.request \? "" : current/); assert.match(pane, /if \(current !== submitted\.request\) return current;/);
assert.match(pane, /inputSelectionRef\.current = undefined;/);
assert.match(pane, /input\.setSelectionRange\(/);
assert.match(pane, /onSelect=\{\(event\) => recordInputSelection/);
assert.match(pane, /onInput=\{\(event\) => recordInputChangeSelection/);
assert.match(pane, /<ThinkingSelector/);
assert.match(pane, /quick-pane-catalog-overlay/);
assert.match(pane, /readOnly=\{runActive\}/); assert.match(pane, /readOnly=\{runActive\}/);
assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/); assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/);
assert.doesNotMatch(pane, /\.slice\(0, 5\)/); assert.doesNotMatch(pane, /\.slice\(0, 5\)/);
assert.doesNotMatch(pane, /aria-label="Answer history"/); assert.doesNotMatch(pane, /aria-label="Answer history"/);
assert.match( assert.match(
pane, pane,
/transcriptMessages\(snapshot, submittedPromptRequestsRef\.current\)/, /transcriptMessages\(snapshot, submittedPromptTurnsRef\.current\)/,
); );
assert.match(pane, /const QUICK_PANE_ADVISORY_PREFIX =/); assert.match(pane, /const QUICK_PANE_ADVISORY_PREFIX =/);
assert.match(pane, /submittedPromptRequests\.get\(text\)/); assert.match(pane, /submittedPromptTurns\[nextSubmittedTurn\]/);
assert.match( assert.match(
pane, pane,
/text\.startsWith\(QUICK_PANE_ADVISORY_PREFIX\) \? "" : text/, /text\.startsWith\(QUICK_PANE_ADVISORY_PREFIX\) \? "" : text/,
); );
assert.match( assert.match(pane, /submittedPromptTurnsRef\.current\.push\(submittedTurn\)/);
pane, assert.match(pane, /nextSubmittedTurnIdRef\.current/);
/submittedPromptRequestsRef\.current\.set\(guardrails, submittedRequest\)/, assert.match(pane, /submittedPromptTurnsRef\.current = \[\]/);
);
assert.match(pane, /submittedPromptRequestsRef\.current\.clear\(\)/);
assert.doesNotMatch( assert.doesNotMatch(
pane, pane,
/delimiterIndex|text\.indexOf\(USER_REQUEST_DELIMITER\)/, /delimiterIndex|text\.indexOf\(USER_REQUEST_DELIMITER\)/,
@@ -234,7 +238,19 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
assert.match(source, /className="quick-pane-drag-strip"/); assert.match(source, /className="quick-pane-drag-strip"/);
assert.match(source, /startDragging\(\)/); assert.match(source, /startDragging\(\)/);
assert.match(source, /className="quick-pane-request"/); assert.match(source, /className="quick-pane-request"/);
assert.match(source, /className="quick-pane-exchange"/); assert.match(source, /className=\{`quick-pane-exchange/);
assert.match(source, /onOpen=\{handleModelMenuOpen\}/);
assert.match(source, /onClose=\{handleModelMenuClose\}/);
assert.match(source, /requestPaneResize\(520\)/);
assert.match(source, /if \(modelMenuOpenRef\.current\) return;/);
assert.match(source, /submittedPromptTurnsRef/);
assert.match(source, /function elapsedLabel/);
assert.match(source, /quick-pane-assistant-activity/);
assert.match(source, /transcriptFollowRef/);
assert.match(
source,
/transcript\.scrollHeight\s*-\s*transcript\.clientHeight/,
);
assert.match(source, /className="quick-pane-composer"/); assert.match(source, /className="quick-pane-composer"/);
assert.match(source, /className="quick-pane-composer-actions"/); assert.match(source, /className="quick-pane-composer-actions"/);
assert.match(source, /replaceChat\(false, true\)/); assert.match(source, /replaceChat\(false, true\)/);
@@ -261,8 +277,16 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
/\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/, /\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/,
); );
assert.match(stylesheet, /\.quick-pane-drag-strip[\s\S]*?height: 8px/); assert.match(stylesheet, /\.quick-pane-drag-strip[\s\S]*?height: 8px/);
assert.match(stylesheet, /\.quick-pane-composer-actions[\s\S]*?position: absolute/); assert.match(
stylesheet,
/\.quick-pane-composer-actions[\s\S]*?position: absolute/,
);
assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/); assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/);
assert.match(
stylesheet,
/\.quick-pane-assistant-elapsed[\s\S]*?font-variant-numeric/,
);
assert.match(stylesheet, /\.quick-pane-assistant-activity[\s\S]*?color:/);
assert.match(stylesheet, /@media \(max-width: 400px\)/); assert.match(stylesheet, /@media \(max-width: 400px\)/);
assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/); assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/);
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: hidden/); assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: hidden/);
@@ -288,6 +312,9 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
), ),
]); ]);
assert.match(selectorSource, /import "\.\/ModelSelector\.css";/); assert.match(selectorSource, /import "\.\/ModelSelector\.css";/);
assert.match(selectorSource, /onOpen\?: \(\) => void;/);
assert.match(selectorSource, /onClose\?: \(\) => void;/);
assert.match(selectorSource, /onCloseRef\.current\?\.\(\);/);
assert.match( assert.match(
selectorStylesheet, selectorStylesheet,
/\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/, /\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/,
+22 -9
View File
@@ -283,15 +283,16 @@ async fn get_model_catalog() -> Result<Value, String> {
bridge::get_model_catalog(&socket_path()?).await bridge::get_model_catalog(&socket_path()?).await
} }
fn quick_pane_height(height: f64) -> f64 { fn quick_pane_height(height: f64) -> Result<f64, String> {
height.clamp(240.0, 520.0) if !height.is_finite() {
return Err("Quick Pane height must be finite".to_owned());
}
Ok(height.clamp(240.0, 520.0))
} }
#[tauri::command] #[tauri::command]
fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> { fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
if !height.is_finite() { let height = quick_pane_height(height)?;
return Err("Quick Pane height must be finite".to_owned());
}
let quick_pane = app let quick_pane = app
.get_webview_window("quick-pane") .get_webview_window("quick-pane")
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?; .ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
@@ -300,7 +301,7 @@ fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
quick_pane quick_pane
.set_size(LogicalSize::new( .set_size(LogicalSize::new(
f64::from(current_size.width) / scale_factor, f64::from(current_size.width) / scale_factor,
quick_pane_height(height), height,
)) ))
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
@@ -802,9 +803,21 @@ mod tests {
#[test] #[test]
fn quick_pane_height_stays_within_content_limit() { fn quick_pane_height_stays_within_content_limit() {
assert_eq!(quick_pane_height(200.0), 240.0); assert_eq!(quick_pane_height(200.0), Ok(240.0));
assert_eq!(quick_pane_height(420.0), 420.0); assert_eq!(quick_pane_height(420.0), Ok(420.0));
assert_eq!(quick_pane_height(900.0), 520.0); assert_eq!(quick_pane_height(900.0), Ok(520.0));
}
#[test]
fn quick_pane_resize_rejects_non_finite_height() {
assert_eq!(
quick_pane_height(f64::NAN),
Err("Quick Pane height must be finite".to_owned())
);
assert_eq!(
quick_pane_height(f64::INFINITY),
Err("Quick Pane height must be finite".to_owned())
);
} }
#[test] #[test]
-35
View File
@@ -717,41 +717,6 @@ select:focus-visible {
border-radius: 5px; border-radius: 5px;
background: #303030; background: #303030;
} }
.pi-controls label {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
align-items: center;
font-size: 10px;
}
.pi-controls select {
min-width: 0;
appearance: none;
color-scheme: dark;
padding: 5px 25px 5px 6px;
border: 1px solid #70522f;
border-radius: 4px;
background: #2c2a27
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
no-repeat right 7px center;
color: #f2ece4;
}
.pi-controls select:hover {
border-color: #d88735;
background-color: #383129;
}
.pi-controls select:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
.pi-controls select option {
background: #2c2a27;
color: #f2ece4;
}
.pi-controls select option:checked {
background: #4b3420;
color: #fff2df;
}
.session-status-strip { .session-status-strip {
display: flex; display: flex;
min-width: 0; min-width: 0;
+12 -14
View File
@@ -3,6 +3,7 @@ import type { Model, RuntimeView, TodoTask } from "../workspace/types";
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc"; import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
import { CommandPalette } from "./CommandPalette"; import { CommandPalette } from "./CommandPalette";
import { ModelSelector } from "./ModelSelector"; import { ModelSelector } from "./ModelSelector";
import { ThinkingSelector } from "./ThinkingSelector";
type SessionStatus = { type SessionStatus = {
label: string; label: string;
@@ -185,6 +186,11 @@ export function ConversationWorkspace({
const activeTool = runtime.progress.tool const activeTool = runtime.progress.tool
? plainStatusText(runtime.progress.tool) ? plainStatusText(runtime.progress.tool)
: undefined; : undefined;
const selectedThinkingModel = runtime.models.find(
(model) =>
model.provider === runtime.state.model?.provider &&
model.id === runtime.state.model?.id,
);
const inputTokens = runtime.stats.tokens?.input; const inputTokens = runtime.stats.tokens?.input;
const outputTokens = runtime.stats.tokens?.output; const outputTokens = runtime.stats.tokens?.output;
const cacheReadTokens = runtime.stats.tokens?.cacheRead; const cacheReadTokens = runtime.stats.tokens?.cacheRead;
@@ -330,20 +336,12 @@ export function ConversationWorkspace({
onSelect={onModel} onSelect={onModel}
pending={isModelUpdating || locked} pending={isModelUpdating || locked}
/> />
<label> <ThinkingSelector
Thinking model={selectedThinkingModel}
<select value={runtime.state.thinkingLevel ?? "medium"}
value={runtime.state.thinkingLevel ?? "medium"} disabled={isModelUpdating || locked}
disabled={isModelUpdating || locked} onChange={(level) => void onThinking(level)}
onChange={(event) => void onThinking(event.currentTarget.value)} />
>
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
(level) => (
<option key={level}>{level}</option>
),
)}
</select>
</label>
</section> </section>
<div <div
className={`session-status-strip ${presentedStatus.phase}`} className={`session-status-strip ${presentedStatus.phase}`}
+46
View File
@@ -0,0 +1,46 @@
.thinking-selector {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
color: #bbb7b0;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.thinking-selector.thinking-selector select {
box-sizing: border-box;
width: 100%;
min-width: 0;
appearance: none;
color: #f2ece4;
color-scheme: dark;
padding: 5px 25px 5px 6px;
border: 1px solid #70522f;
border-radius: 4px;
background: #2c2a27
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
no-repeat right 7px center;
}
.thinking-selector.thinking-selector select:hover {
border-color: #d88735;
background-color: #383129;
}
.thinking-selector.thinking-selector select:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
.thinking-selector.thinking-selector select option {
background: #2c2a27;
color: #f2ece4;
}
.thinking-selector.thinking-selector select option:checked {
background: #4b3420;
color: #fff2df;
}
@@ -0,0 +1,27 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { ThinkingSelector } from "./ThinkingSelector";
it("uses selected model thinking capabilities and reconciles unavailable values", () => {
const onChange = vi.fn();
render(
<ThinkingSelector
model={{ thinkingLevelMap: { off: "off", high: "high" } }}
value="medium"
onChange={onChange}
/>,
);
const selector = screen.getByRole("combobox", { name: "Thinking" });
expect(selector).toHaveValue("off");
expect(
within(selector).getAllByRole("option").map((option) => option.textContent),
).toEqual(["Off", "High"]);
fireEvent.change(selector, { target: { value: "high" } });
expect(onChange).toHaveBeenCalledWith("high");
});
it("uses disabled native select semantics", () => {
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
expect(screen.getByRole("combobox", { name: "Thinking" })).toBeDisabled();
});
+40
View File
@@ -0,0 +1,40 @@
import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog";
import type { Model } from "../workspace/types";
import "./ThinkingSelector.css";
type ThinkingSelectorProps = {
model?: Model;
value: string;
disabled?: boolean;
className?: string;
onChange: (level: string) => void;
};
export function ThinkingSelector({
model,
value,
disabled = false,
className,
onChange,
}: ThinkingSelectorProps) {
const selected = reconcileThinkingLevel(model, value);
const classes = ["thinking-selector", className].filter(Boolean).join(" ");
return (
<label className={classes}>
<span>Thinking</span>
<select
aria-label="Thinking"
value={selected}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
>
{thinkingLevelOptions(model).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
+445 -68
View File
@@ -230,7 +230,7 @@ describe("QuickPane", () => {
await preparedTrigger(); await preparedTrigger();
const pane = container.querySelector(".quick-pane"); const pane = container.querySelector(".quick-pane");
expect( expect(
Array.from(pane?.children ?? []).map((element) => element.className), Array.from(pane?.children ?? []).map((element) => element.classList[0]),
).toEqual([ ).toEqual([
"quick-pane-drag-strip", "quick-pane-drag-strip",
"quick-pane-toolbar", "quick-pane-toolbar",
@@ -245,10 +245,144 @@ describe("QuickPane", () => {
await preparedTrigger(); await preparedTrigger();
fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!); fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!);
expect(mocks.startDragging).toHaveBeenCalledTimes(1); expect(mocks.startDragging).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(screen.getByRole("button", { name: "Close Quick Pane" })); fireEvent.pointerDown(
screen.getByRole("button", { name: "Close Quick Pane" }),
);
expect(mocks.startDragging).toHaveBeenCalledTimes(1); expect(mocks.startDragging).toHaveBeenCalledTimes(1);
}); });
it("blocks pane interaction behind one catalog loading overlay", async () => {
const tracker = deferredCatalogBridge();
render(<QuickPane />);
const overlay = screen.getByRole("dialog", { name: "Model catalog" });
expect(overlay).toHaveTextContent("Loading models…");
expect(screen.getAllByText("Loading models…")).toHaveLength(1);
expect(screen.getByRole("textbox", { name: "Request" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Close Quick Pane" }),
).toBeDisabled();
await act(async () =>
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
);
expect(await preparedTrigger()).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Model catalog" })).toBeNull();
expect(screen.getByRole("textbox", { name: "Request" })).toBeEnabled();
});
it("restores textarea caret after Tab to Send and Shift+Tab", async () => {
bridgeMock([defaultModels]);
const user = userEvent.setup();
render(<QuickPane />);
await preparedTrigger();
const input = screen.getByRole("textbox", {
name: "Request",
}) as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Inspect selection" } });
input.focus();
input.setSelectionRange(7, 7, "none");
fireEvent.select(input);
await user.tab();
expect(screen.getByRole("button", { name: "Send request" })).toHaveFocus();
await user.tab({ shift: true });
await waitFor(() => expect(input).toHaveFocus());
await waitFor(() => {
expect(input.selectionStart).toBe(7);
expect(input.selectionEnd).toBe(7);
expect(input.selectionDirection).toBe("none");
});
});
it("keeps browser click selection instead of restoring stale range", async () => {
bridgeMock([defaultModels]);
render(<QuickPane />);
await preparedTrigger();
const input = screen.getByRole("textbox", {
name: "Request",
}) as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Inspect selection" } });
input.focus();
input.setSelectionRange(2, 2, "none");
fireEvent.select(input);
input.blur();
fireEvent.pointerDown(input);
input.focus();
input.setSelectionRange(11, 11, "none");
fireEvent.select(input);
fireEvent.click(input);
await new Promise((resolve) => window.setTimeout(resolve, 20));
expect(input.selectionStart).toBe(11);
expect(input.selectionEnd).toBe(11);
});
it("restores selection direction on programmatic focus without rewriting value", async () => {
bridgeMock([defaultModels]);
render(<QuickPane />);
await preparedTrigger();
const input = screen.getByRole("textbox", {
name: "Request",
}) as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Inspect selection" } });
input.focus();
input.setSelectionRange(2, 9, "backward");
fireEvent.select(input);
input.blur();
const descriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)!;
const setValue = vi.fn(function (this: HTMLTextAreaElement, value: string) {
descriptor.set!.call(this, value);
});
Object.defineProperty(input, "value", {
configurable: true,
get: () => descriptor.get!.call(input),
set: setValue,
});
input.focus();
await waitFor(() => expect(input).toHaveFocus());
await waitFor(() => {
expect(input.selectionStart).toBe(2);
expect(input.selectionEnd).toBe(9);
expect(input.selectionDirection).toBe("backward");
});
expect(setValue).not.toHaveBeenCalled();
});
it("does not restore stale selection after completion clears and focuses draft", async () => {
let snapshot = runtimeSnapshot(defaultModels, "Partial", true);
bridgeMock([defaultModels], () => snapshot);
const user = userEvent.setup();
render(<QuickPane />);
await preparedTrigger();
const input = screen.getByRole("textbox", {
name: "Request",
}) as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Inspect selection" } });
input.focus();
input.setSelectionRange(2, 9, "backward");
fireEvent.select(input);
await user.click(screen.getByRole("button", { name: "Send request" }));
await waitFor(() => expect(input).toHaveAttribute("readonly"));
snapshot = runtimeSnapshot(defaultModels, "Completed", false);
expect(await screen.findByText("Completed")).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
await waitFor(() => expect(input).toHaveFocus());
await new Promise((resolve) => window.setTimeout(resolve, 20));
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(0);
expect(input.selectionDirection).toBe("none");
});
it("derives thinking choices from selected model metadata and falls back to Off", async () => { it("derives thinking choices from selected model metadata and falls back to Off", async () => {
const thinkingModels: Model[] = [ const thinkingModels: Model[] = [
{ {
@@ -258,14 +392,17 @@ describe("QuickPane", () => {
thinkingLevelMap: { off: "off", high: "high" }, thinkingLevelMap: { off: "off", high: "high" },
}, },
]; ];
bridgeMock([thinkingModels], () => runtimeSnapshot(thinkingModels, "", true)); bridgeMock([thinkingModels], () =>
runtimeSnapshot(thinkingModels, "", true),
);
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
const thinking = screen.getByRole("combobox", { name: "Thinking" }); const thinking = screen.getByRole("combobox", { name: "Thinking" });
expect(within(thinking).getAllByRole("option").map((option) => option.textContent)).toEqual([ expect(
"Off", within(thinking)
"High", .getAllByRole("option")
]); .map((option) => option.textContent),
).toEqual(["Off", "High"]);
await userEvent.selectOptions(thinking, "high"); await userEvent.selectOptions(thinking, "high");
await ask("Inspect"); await ask("Inspect");
await waitFor(() => await waitFor(() =>
@@ -291,7 +428,9 @@ describe("QuickPane", () => {
await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" }); expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" });
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("button", { name: "Send request" })).toBeEnabled(), expect(
screen.getByRole("button", { name: "Send request" }),
).toBeEnabled(),
); );
streaming = false; streaming = false;
@@ -304,10 +443,13 @@ describe("QuickPane", () => {
runtimeSnapshot(defaultModels, "Partial answer", true), runtimeSnapshot(defaultModels, "Partial answer", true),
); );
const baseImplementation = mocks.invoke.getMockImplementation(); const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation((command: string, payload?: Record<string, unknown>) => { mocks.invoke.mockImplementation(
if (command === "abort") return Promise.reject(new Error("abort unavailable")); (command: string, payload?: Record<string, unknown>) => {
return baseImplementation?.(command, payload); if (command === "abort")
}); return Promise.reject(new Error("abort unavailable"));
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
await ask("Inspect"); await ask("Inspect");
@@ -340,18 +482,36 @@ describe("QuickPane", () => {
).toHaveClass("model-results"); ).toHaveClass("model-results");
}); });
it("closes an open selector on first Escape and dismisses pane on second", async () => { it("grows for an open selector, restores content height on Escape, then dismisses", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
bridgeMock([defaultModels]); bridgeMock([defaultModels]);
render(<QuickPane />); render(<QuickPane />);
const trigger = await preparedTrigger(); const trigger = await preparedTrigger();
await user.click(trigger); await user.click(trigger);
await waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus()); await waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("resize_quick_pane", {
height: 520,
}),
);
const resizeCallCountBeforeClose = mocks.invoke.mock.calls.filter(
([command]) => command === "resize_quick_pane",
).length;
fireEvent.keyDown(window, { key: "Escape" }); fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull(); expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane"); expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
await waitFor(() => expect(trigger).toHaveFocus()); await waitFor(() => expect(trigger).toHaveFocus());
await waitFor(() => {
const resizeCalls = mocks.invoke.mock.calls.filter(
([command]) => command === "resize_quick_pane",
);
expect(resizeCalls.length).toBeGreaterThan(resizeCallCountBeforeClose);
expect(resizeCalls[resizeCalls.length - 1]).toEqual([
"resize_quick_pane",
{ height: 240 },
]);
});
fireEvent.keyDown(window, { key: "Escape" }); fireEvent.keyDown(window, { key: "Escape" });
await waitFor(() => await waitFor(() =>
@@ -359,6 +519,41 @@ describe("QuickPane", () => {
); );
}); });
it("retries a failed native menu expansion while selector remains open", async () => {
bridgeMock([defaultModels]);
const baseImplementation = mocks.invoke.getMockImplementation();
let failed = false;
mocks.invoke.mockImplementation(
(command: string, payload?: { height?: number }) => {
if (
command === "resize_quick_pane" &&
payload?.height === 520 &&
!failed
) {
failed = true;
return Promise.reject(new Error("resize unavailable"));
}
return baseImplementation?.(command, payload);
},
);
const user = userEvent.setup();
render(<QuickPane />);
await user.click(await preparedTrigger());
await waitFor(() => {
expect(
mocks.invoke.mock.calls.filter(
([command, payload]) =>
command === "resize_quick_pane" &&
(payload as { height?: number })?.height === 520,
),
).toHaveLength(2);
});
expect(
screen.getByRole("dialog", { name: "Choose model" }),
).toBeInTheDocument();
});
it("allows only one submission start while commands are pending", async () => { it("allows only one submission start while commands are pending", async () => {
const pendingModel = deferred<unknown>(); const pendingModel = deferred<unknown>();
const tracker = bridgeMock([defaultModels]); const tracker = bridgeMock([defaultModels]);
@@ -412,14 +607,21 @@ describe("QuickPane", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("clears only submitted draft and restores focus after completion", async () => { it("shows current humanized activity inside streaming assistant card", async () => {
let current = runtimeSnapshot(defaultModels, "Partial answer", true); let current = runtimeSnapshot(defaultModels, "Partial answer", true, {
...runtimeSnapshot(defaultModels).runtime,
activeTool: "functions.read",
});
bridgeMock([defaultModels], () => current); bridgeMock([defaultModels], () => current);
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
const input = screen.getByRole("textbox", { name: "Request" }); const input = screen.getByRole("textbox", { name: "Request" });
await ask("Inspect this"); await ask("Inspect this");
expect(await screen.findByText("Partial answer")).toBeInTheDocument(); const partialAnswer = await screen.findByText("Partial answer");
expect(partialAnswer).toBeInTheDocument();
expect(
within(partialAnswer.closest("article")!).getByText("Reading file"),
).toHaveClass("quick-pane-assistant-activity");
expect(input).toHaveValue("Inspect this"); expect(input).toHaveValue("Inspect this");
expect(input).toHaveAttribute("readonly"); expect(input).toHaveAttribute("readonly");
@@ -435,6 +637,32 @@ describe("QuickPane", () => {
expect(input).toHaveValue("Next draft"); expect(input).toHaveValue("Next draft");
}); });
it("follows transcript near bottom without overriding manual scroll-up", async () => {
let current = runtimeSnapshot(defaultModels, "Partial answer", true);
bridgeMock([defaultModels], () => current);
render(<QuickPane />);
await preparedTrigger();
await ask("Inspect this");
const response = screen.getByRole("region", { name: "Assistant response" });
Object.defineProperties(response, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 1_000 },
scrollTop: { configurable: true, value: 880, writable: true },
});
fireEvent.scroll(response);
current = runtimeSnapshot(defaultModels, "Updated answer", true);
expect(await screen.findByText("Updated answer")).toBeInTheDocument();
await waitFor(() => expect(response.scrollTop).toBe(1_000));
response.scrollTop = 100;
fireEvent.scroll(response);
current = runtimeSnapshot(defaultModels, "Latest answer", true);
expect(await screen.findByText("Latest answer")).toBeInTheDocument();
await new Promise((resolve) => window.setTimeout(resolve, 50));
expect(response.scrollTop).toBe(100);
});
it("shows manual continuation after a completed normal answer and hands off transcript", async () => { it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
bridgeMock([defaultModels], () => bridgeMock([defaultModels], () =>
snapshotWithTranscript([ snapshotWithTranscript([
@@ -459,7 +687,9 @@ describe("QuickPane", () => {
const continueButton = await screen.findByRole("button", { const continueButton = await screen.findByRole("button", {
name: "Continue in full session", name: "Continue in full session",
}); });
expect(continueButton.closest(".quick-pane-assistant-footer")).not.toBeNull(); expect(
continueButton.closest(".quick-pane-assistant-footer"),
).not.toBeNull();
expect(mocks.invoke).not.toHaveBeenCalledWith( expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_session_runtime", "create_session_runtime",
expect.anything(), expect.anything(),
@@ -533,7 +763,11 @@ describe("QuickPane", () => {
const cards = screen const cards = screen
.getByRole("region", { name: "Assistant response" }) .getByRole("region", { name: "Assistant response" })
.querySelectorAll("article"); .querySelectorAll("article");
expect(Array.from(cards).map((card) => card.textContent)).toEqual([ expect(
Array.from(cards).map((card) =>
card.textContent?.replace(/\d+(?:m \d+)?s/, ""),
),
).toEqual([
"YouFirst request", "YouFirst request",
"AssistantFirst answer", "AssistantFirst answer",
"YouSecond request", "YouSecond request",
@@ -541,6 +775,196 @@ describe("QuickPane", () => {
]); ]);
}); });
it("keeps distinct elapsed times for duplicate follow-up requests", async () => {
let now = 100_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const submittedPrompts: string[] = [];
let firstComplete = false;
let secondComplete = false;
bridgeMock([defaultModels], () => {
const messages: Message[] = [];
if (submittedPrompts[0])
messages.push(
{ role: "user", content: submittedPrompts[0] },
{ role: "assistant", content: "First answer" },
);
if (submittedPrompts[1])
messages.push(
{ role: "user", content: submittedPrompts[1] },
{ role: "assistant", content: "Second answer" },
);
return snapshotWithTranscript(
messages,
submittedPrompts.length === 1 ? !firstComplete : !secondComplete,
);
});
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "submit_prompt" && payload?.message)
submittedPrompts.push(String(payload.message));
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
await ask("Same request");
expect(await screen.findByText("First answer")).toBeInTheDocument();
now = 165_000;
firstComplete = true;
await waitFor(() =>
expect(screen.getByText("1m 5s")).toHaveClass(
"quick-pane-assistant-elapsed",
),
);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Request" }),
).not.toHaveAttribute("readonly"),
);
now = 166_000;
await ask("Same request");
expect(submittedPrompts[1]).toBe(submittedPrompts[0]);
now = 168_000;
secondComplete = true;
await waitFor(() =>
expect(screen.getByText("2s")).toHaveClass(
"quick-pane-assistant-elapsed",
),
);
const assistantCards = screen
.getByRole("region", { name: "Assistant response" })
.querySelectorAll(".quick-pane-assistant-message");
expect(
within(assistantCards[0]! as HTMLElement).getByText("1m 5s"),
).toHaveClass("quick-pane-assistant-elapsed");
expect(
within(assistantCards[1]! as HTMLElement).getByText("2s"),
).toHaveClass("quick-pane-assistant-elapsed");
});
it.each(["set_model", "set_thinking_level", "submit_prompt"])(
"keeps only retry timing when %s rejects before first submission",
async (failingCommand) => {
let now = 100_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
let failed = false;
let acceptedPrompt = "";
let complete = false;
bridgeMock([defaultModels], () =>
acceptedPrompt
? snapshotWithTranscript(
[
{ role: "user", content: acceptedPrompt },
{ role: "assistant", content: "Retry answer" },
],
!complete,
)
: runtimeSnapshot(defaultModels),
);
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === failingCommand && !failed) {
failed = true;
return Promise.reject(new Error(`${failingCommand} unavailable`));
}
if (command === "submit_prompt" && payload?.message)
acceptedPrompt = String(payload.message);
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
await ask("Same request");
expect(
await screen.findByText(
`Could not start quick request: Error: ${failingCommand} unavailable`,
),
).toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Request" }),
).not.toHaveAttribute("readonly"),
);
now = 200_000;
await userEvent.click(
screen.getByRole("button", { name: "Send request" }),
);
await waitFor(() => expect(acceptedPrompt).not.toBe(""));
now = 202_000;
complete = true;
await waitFor(
() =>
expect(screen.getByText("2s")).toHaveClass(
"quick-pane-assistant-elapsed",
),
{ timeout: 2_000 },
);
expect(screen.queryByText("1m 42s")).toBeNull();
},
);
it("does not let a failed different request claim later timing", async () => {
let now = 100_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
let failed = false;
let acceptedPrompt = "";
let complete = false;
bridgeMock([defaultModels], () =>
acceptedPrompt
? snapshotWithTranscript(
[
{ role: "user", content: acceptedPrompt },
{ role: "assistant", content: "Second answer" },
],
!complete,
)
: runtimeSnapshot(defaultModels),
);
const baseImplementation = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: Record<string, unknown>) => {
if (command === "submit_prompt" && !failed) {
failed = true;
return Promise.reject(new Error("submit unavailable"));
}
if (command === "submit_prompt" && payload?.message)
acceptedPrompt = String(payload.message);
return baseImplementation?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
await ask("Discard this");
expect(
await screen.findByText(
"Could not start quick request: Error: submit unavailable",
),
).toBeInTheDocument();
const input = screen.getByRole("textbox", { name: "Request" });
fireEvent.change(input, { target: { value: "Keep this" } });
now = 200_000;
await userEvent.click(screen.getByRole("button", { name: "Send request" }));
await waitFor(() => expect(acceptedPrompt).toContain("Keep this"));
now = 202_000;
complete = true;
await waitFor(
() =>
expect(screen.getByText("2s")).toHaveClass(
"quick-pane-assistant-elapsed",
),
{ timeout: 2_000 },
);
expect(screen.queryByText("Discard this")).toBeNull();
});
it("does not use prior-turn answer after follow-up runtime failure", async () => { it("does not use prior-turn answer after follow-up runtime failure", async () => {
const submittedPrompts: string[] = []; const submittedPrompts: string[] = [];
bridgeMock([defaultModels], () => { bridgeMock([defaultModels], () => {
@@ -856,7 +1280,7 @@ describe("QuickPane", () => {
}); });
render(<QuickPane />); render(<QuickPane />);
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alertdialog");
expect(alert).toHaveTextContent( expect(alert).toHaveTextContent(
"Could not load settings: Error: state unavailable", "Could not load settings: Error: state unavailable",
); );
@@ -890,7 +1314,7 @@ describe("QuickPane", () => {
}, },
); );
render(<QuickPane />); render(<QuickPane />);
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alertdialog");
expect(alert).toHaveTextContent( expect(alert).toHaveTextContent(
"Could not prepare model catalog: Error: offline", "Could not prepare model catalog: Error: offline",
); );
@@ -997,53 +1421,6 @@ describe("QuickPane fresh runtime catalog", () => {
}); });
}); });
it.each([
{
action: "Close",
dismiss: async () => {
await userEvent.click(
screen.getByRole("button", { name: "Close Quick Pane" }),
);
},
},
{
action: "Escape",
dismiss: async () => {
fireEvent.keyDown(window, { key: "Escape" });
},
},
])(
"retains deferred catalog preparation across $action dismissal",
async ({ dismiss }) => {
const tracker = deferredCatalogBridge();
render(<QuickPane />);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith(
"get_session_runtime_snapshot",
{ runtimeId: "runtime-1" },
),
);
await dismiss();
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)),
);
expect(
await screen.findByRole("button", { name: /provider · Stale/i }),
).toBeInTheDocument();
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(tracker.runtimeCount()).toBe(1);
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
},
);
it("retains deferred catalog preparation across idle focus loss", async () => { it("retains deferred catalog preparation across idle focus loss", async () => {
const tracker = deferredCatalogBridge(); const tracker = deferredCatalogBridge();
render(<QuickPane />); render(<QuickPane />);
@@ -1292,7 +1669,7 @@ describe("QuickPane fresh runtime catalog", () => {
return Promise.resolve(undefined); return Promise.resolve(undefined);
}); });
render(<QuickPane />); render(<QuickPane />);
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alertdialog")).toHaveTextContent(
"Could not prepare model catalog: Error: offline", "Could not prepare model catalog: Error: offline",
); );
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
+405 -87
View File
@@ -6,12 +6,12 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog"; import { ExtensionDialog } from "../components/ExtensionDialog";
import { ModelSelector } from "../components/ModelSelector"; import { ModelSelector } from "../components/ModelSelector";
import { ThinkingSelector } from "../components/ThinkingSelector";
import { import {
modelIdentity, modelIdentity,
modelIdentityValue, modelIdentityValue,
reconcileModelIdentity, reconcileModelIdentity,
reconcileThinkingLevel, reconcileThinkingLevel,
thinkingLevelOptions,
type ModelIdentity, type ModelIdentity,
} from "../model-catalog"; } from "../model-catalog";
import "./quick-pane.css"; import "./quick-pane.css";
@@ -43,15 +43,32 @@ const defaults: QuickPaneSettings = {
type Runtime = { agentId: string; runtimeId: string }; type Runtime = { agentId: string; runtimeId: string };
type PreparedRuntime = { runtime: Runtime; models: Model[] }; type PreparedRuntime = { runtime: Runtime; models: Model[] };
type TurnTiming = {
startedAt: number;
completedAt?: number;
};
type SubmittedPromptTurn = {
id: number;
guardrails: string;
request: string;
timing: TurnTiming;
};
type TranscriptMessage = { type TranscriptMessage = {
id: string; id: string;
role: "user" | "assistant"; role: "user" | "assistant";
markdown: string; markdown: string;
timing?: TurnTiming;
}; };
type ActivitySnapshot = { type ActivitySnapshot = {
activeTool?: string; activeTool?: string;
hasAssistantText: boolean; hasAssistantText: boolean;
}; };
type TextSelection = {
value: string;
start: number;
end: number;
direction: HTMLTextAreaElement["selectionDirection"];
};
const CHAT_RENEWAL_MS = 180_000; const CHAT_RENEWAL_MS = 180_000;
@@ -71,39 +88,56 @@ function visibleAssistantText(text: string) {
: text; : text;
} }
function visibleUserText( function visibleUserText(text: string, submittedTurn?: SubmittedPromptTurn) {
text: string, if (submittedTurn) return submittedTurn.request;
submittedPromptRequests: ReadonlyMap<string, string>,
) {
const submittedRequest = submittedPromptRequests.get(text);
if (submittedRequest !== undefined) return submittedRequest;
return text.startsWith(QUICK_PANE_ADVISORY_PREFIX) ? "" : text; return text.startsWith(QUICK_PANE_ADVISORY_PREFIX) ? "" : text;
} }
function transcriptMessages( function transcriptMessages(
snapshot: RuntimeSnapshot, snapshot: RuntimeSnapshot,
submittedPromptRequests: ReadonlyMap<string, string>, submittedPromptTurns: readonly SubmittedPromptTurn[],
): TranscriptMessage[] { ): TranscriptMessage[] {
let nextSubmittedTurn = 0;
let activeTurn: SubmittedPromptTurn | undefined;
return (snapshot.transcript?.data?.messages ?? []).flatMap( return (snapshot.transcript?.data?.messages ?? []).flatMap(
(message, index) => { (message, index) => {
if (message.role !== "user" && message.role !== "assistant") return []; if (message.role !== "user" && message.role !== "assistant") return [];
const text = messageText(message); const text = messageText(message);
let submittedTurn: SubmittedPromptTurn | undefined;
if (message.role === "user") {
const candidate = submittedPromptTurns[nextSubmittedTurn];
if (candidate?.guardrails === text) {
submittedTurn = candidate;
activeTurn = candidate;
nextSubmittedTurn += 1;
} else activeTurn = undefined;
}
const markdown = const markdown =
message.role === "assistant" message.role === "assistant"
? visibleAssistantText(text) ? visibleAssistantText(text)
: visibleUserText(text, submittedPromptRequests); : visibleUserText(text, submittedTurn);
if (!markdown) return []; if (!markdown) return [];
return [ return [
{ {
id: `${index}-${message.role}`, id: `${index}-${message.role}`,
role: message.role, role: message.role,
markdown, markdown,
timing: message.role === "assistant" ? activeTurn?.timing : undefined,
}, },
]; ];
}, },
); );
} }
function elapsedLabel(startedAt: number, completedAt?: number) {
const seconds = Math.max(
0,
Math.floor(((completedAt ?? Date.now()) - startedAt) / 1_000),
);
if (seconds < 60) return `${seconds}s`;
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
function snapshotIsActive(snapshot: RuntimeSnapshot) { function snapshotIsActive(snapshot: RuntimeSnapshot) {
return ( return (
snapshot.state?.data?.isStreaming === true || snapshot.state?.data?.isStreaming === true ||
@@ -131,19 +165,17 @@ function settingsError(settings: QuickPaneSettings) {
function currentTurnAssistantText( function currentTurnAssistantText(
snapshot: RuntimeSnapshot, snapshot: RuntimeSnapshot,
submittedPromptRequests: ReadonlyMap<string, string>, submittedPromptTurns: readonly SubmittedPromptTurn[],
) { ) {
const messages = snapshot.transcript?.data?.messages ?? []; const messages = snapshot.transcript?.data?.messages ?? [];
let nextSubmittedTurn = 0;
let submittedUserIndex = -1; let submittedUserIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) { for (const [index, message] of messages.entries()) {
const message = messages[index]; if (message.role !== "user") continue;
if ( const candidate = submittedPromptTurns[nextSubmittedTurn];
message.role === "user" && if (candidate?.guardrails !== messageText(message)) continue;
submittedPromptRequests.has(messageText(message)) submittedUserIndex = index;
) { nextSubmittedTurn += 1;
submittedUserIndex = index;
break;
}
} }
if (submittedUserIndex === -1) return ""; if (submittedUserIndex === -1) return "";
@@ -176,6 +208,8 @@ export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults); const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [request, setRequest] = useState(""); const [request, setRequest] = useState("");
const requestRef = useRef(request);
requestRef.current = request;
const [submissionStarting, setSubmissionStarting] = useState(false); const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false); const [requestActive, setRequestActive] = useState(false);
const [chatReplacing, setChatReplacing] = useState(false); const [chatReplacing, setChatReplacing] = useState(false);
@@ -184,6 +218,7 @@ export default function QuickPane() {
const [catalogStatus, setCatalogStatus] = useState< const [catalogStatus, setCatalogStatus] = useState<
"loading" | "ready" | "error" "loading" | "ready" | "error"
>("loading"); >("loading");
const [initialCatalogResolved, setInitialCatalogResolved] = useState(false);
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({ const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
provider: "", provider: "",
modelId: "", modelId: "",
@@ -202,6 +237,7 @@ export default function QuickPane() {
hasAssistantText: false, hasAssistantText: false,
}); });
const [elapsedSeconds, setElapsedSeconds] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const runtimeRef = useRef<Runtime | undefined>(undefined); const runtimeRef = useRef<Runtime | undefined>(undefined);
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined); const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds); const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
@@ -212,7 +248,11 @@ export default function QuickPane() {
const submittedRequestRef = useRef< const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined { runtimeId: string; request: string } | undefined
>(undefined); >(undefined);
const submittedPromptRequestsRef = useRef(new Map<string, string>()); const submittedPromptTurnsRef = useRef<SubmittedPromptTurn[]>([]);
const nextSubmittedTurnIdRef = useRef(0);
const activeSubmittedTurnRef = useRef<SubmittedPromptTurn | undefined>(
undefined,
);
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined); const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
const completionHandledRef = useRef(false); const completionHandledRef = useRef(false);
const submissionStartingRef = useRef(false); const submissionStartingRef = useRef(false);
@@ -227,11 +267,27 @@ export default function QuickPane() {
const catalogReadyRef = useRef(false); const catalogReadyRef = useRef(false);
const selectedModelRef = useRef(selectedModel); const selectedModelRef = useRef(selectedModel);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const inputSelectionRef = useRef<TextSelection | undefined>(undefined);
const inputFocusIntentRef = useRef<
"keyboard" | "pointer" | "programmatic" | undefined
>(undefined);
const selectionRestoreFrameRef = useRef<number | undefined>(undefined);
const selectionRestorePendingRef = useRef(false);
const paneRef = useRef<HTMLElement>(null); const paneRef = useRef<HTMLElement>(null);
const exchangeRef = useRef<HTMLElement>(null); const exchangeRef = useRef<HTMLElement>(null);
const transcriptRef = useRef<HTMLDivElement>(null); const transcriptRef = useRef<HTMLDivElement>(null);
const transcriptFollowRef = useRef(true);
const transcriptScrollFrameRef = useRef<number | undefined>(undefined);
const resizeFrameRef = useRef<number | undefined>(undefined); const resizeFrameRef = useRef<number | undefined>(undefined);
const resizeRetryTimeoutRef = useRef<number | undefined>(undefined);
const resizeRetryCountRef = useRef(0);
const requestedPaneHeightRef = useRef<number | undefined>(undefined); const requestedPaneHeightRef = useRef<number | undefined>(undefined);
const resizedPaneHeightRef = useRef<number | undefined>(undefined);
const resizeInFlightRef = useRef(false);
const modelMenuOpenRef = useRef(false);
const requestPaneResizeRef = useRef<((height: number) => void) | undefined>(
undefined,
);
const invalid = useMemo(() => settingsError(settings), [settings]); const invalid = useMemo(() => settingsError(settings), [settings]);
const selectedCatalogModel = useMemo( const selectedCatalogModel = useMemo(
() => () =>
@@ -242,10 +298,6 @@ export default function QuickPane() {
), ),
[models, selectedModel], [models, selectedModel],
); );
const thinkingOptions = useMemo(
() => thinkingLevelOptions(selectedCatalogModel),
[selectedCatalogModel],
);
const runActive = submissionStarting || requestActive; const runActive = submissionStarting || requestActive;
const activityPhase = submissionStarting const activityPhase = submissionStarting
? "Starting" ? "Starting"
@@ -260,6 +312,8 @@ export default function QuickPane() {
activityPhase === "Thinking" activityPhase === "Thinking"
? `${activityPhase} · ${elapsedSeconds}s` ? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase; : activityPhase;
const catalogOverlayActive = !initialCatalogResolved;
const catalogOverlayError = catalogError ?? error;
const hasExchangeContent = const hasExchangeContent =
messages.length > 0 || messages.length > 0 ||
runActive || runActive ||
@@ -278,7 +332,121 @@ export default function QuickPane() {
); );
}, [selectedCatalogModel]); }, [selectedCatalogModel]);
const focusInput = () => inputRef.current?.focus(); const recordInputSelection = (input: HTMLTextAreaElement) => {
if (
selectionRestorePendingRef.current &&
inputFocusIntentRef.current !== "pointer"
)
return;
inputSelectionRef.current = {
value: input.value,
start: input.selectionStart,
end: input.selectionEnd,
direction: input.selectionDirection,
};
};
const recordInputChangeSelection = (input: HTMLTextAreaElement) => {
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
selectionRestoreFrameRef.current = undefined;
selectionRestorePendingRef.current = false;
recordInputSelection(input);
};
const restoreInputSelection = useCallback(() => {
const input = inputRef.current;
const selection = inputSelectionRef.current;
if (
!input ||
!selection ||
selection.value !== input.value ||
selection.value !== requestRef.current
)
return;
const length = input.value.length;
const start = Math.min(Math.max(selection.start, 0), length);
const end = Math.min(Math.max(selection.end, 0), length);
input.setSelectionRange(
Math.min(start, end),
Math.max(start, end),
selection.direction,
);
}, []);
const scheduleInputSelectionRestore = useCallback(() => {
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
selectionRestorePendingRef.current = true;
selectionRestoreFrameRef.current = window.requestAnimationFrame(() => {
selectionRestoreFrameRef.current = undefined;
selectionRestorePendingRef.current = false;
restoreInputSelection();
});
}, [restoreInputSelection]);
const focusInput = useCallback(() => {
const input = inputRef.current;
if (!input || document.activeElement === input) return;
inputFocusIntentRef.current = "programmatic";
input.focus();
}, []);
const handleInputFocus = () => {
const intent = inputFocusIntentRef.current ?? "programmatic";
inputFocusIntentRef.current = undefined;
if (intent !== "pointer") scheduleInputSelectionRestore();
};
const requestPaneResize = useCallback((height: number) => {
if (requestedPaneHeightRef.current !== height)
resizeRetryCountRef.current = 0;
requestedPaneHeightRef.current = height;
if (resizeRetryTimeoutRef.current !== undefined) {
window.clearTimeout(resizeRetryTimeoutRef.current);
resizeRetryTimeoutRef.current = undefined;
}
if (resizeInFlightRef.current || resizedPaneHeightRef.current === height)
return;
resizeInFlightRef.current = true;
void invoke("resize_quick_pane", { height }).then(
() => {
resizeInFlightRef.current = false;
resizedPaneHeightRef.current = height;
const desiredHeight = requestedPaneHeightRef.current;
if (desiredHeight !== undefined && desiredHeight !== height)
requestPaneResizeRef.current?.(desiredHeight);
},
() => {
resizeInFlightRef.current = false;
const desiredHeight = requestedPaneHeightRef.current;
if (desiredHeight === undefined) return;
if (desiredHeight !== height) {
requestPaneResizeRef.current?.(desiredHeight);
return;
}
if (
!mountedRef.current ||
(modelMenuOpenRef.current && height !== 520) ||
resizeRetryCountRef.current >= 1
)
return;
resizeRetryCountRef.current += 1;
resizeRetryTimeoutRef.current = window.setTimeout(() => {
resizeRetryTimeoutRef.current = undefined;
if (
mountedRef.current &&
requestedPaneHeightRef.current === height &&
(!modelMenuOpenRef.current || height === 520)
)
requestPaneResizeRef.current?.(height);
}, 250);
},
);
}, []);
requestPaneResizeRef.current = requestPaneResize;
const handleModelMenuOpen = useCallback(() => {
modelMenuOpenRef.current = true;
setModelMenuOpen(true);
requestPaneResize(520);
}, [requestPaneResize]);
const handleModelMenuClose = useCallback(() => {
modelMenuOpenRef.current = false;
setModelMenuOpen(false);
}, []);
const closeRuntime = useCallback(async (runtimeId: string) => { const closeRuntime = useCallback(async (runtimeId: string) => {
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId); const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
if (pending) return pending; if (pending) return pending;
@@ -365,6 +533,7 @@ export default function QuickPane() {
const emptyModel = { provider: "", modelId: "" }; const emptyModel = { provider: "", modelId: "" };
selectedModelRef.current = emptyModel; selectedModelRef.current = emptyModel;
setSelectedModel(emptyModel); setSelectedModel(emptyModel);
setInitialCatalogResolved(true);
setCatalogStatus("ready"); setCatalogStatus("ready");
setCatalogNotice("No models are currently available."); setCatalogNotice("No models are currently available.");
return false; return false;
@@ -372,6 +541,7 @@ export default function QuickPane() {
selectedModelRef.current = reconciled; selectedModelRef.current = reconciled;
setSelectedModel(reconciled); setSelectedModel(reconciled);
catalogReadyRef.current = true; catalogReadyRef.current = true;
setInitialCatalogResolved(true);
setCatalogStatus("ready"); setCatalogStatus("ready");
setCatalogNotice( setCatalogNotice(
modelIdentityValue(reconciled) === modelIdentityValue(preferred) modelIdentityValue(reconciled) === modelIdentityValue(preferred)
@@ -453,7 +623,9 @@ export default function QuickPane() {
lastUserQuestionAtRef.current = undefined; lastUserQuestionAtRef.current = undefined;
lastCompletedAnswerAtRef.current = undefined; lastCompletedAnswerAtRef.current = undefined;
submittedRequestRef.current = undefined; submittedRequestRef.current = undefined;
submittedPromptRequestsRef.current.clear(); submittedPromptTurnsRef.current = [];
nextSubmittedTurnIdRef.current = 0;
activeSubmittedTurnRef.current = undefined;
promptSubmittedRuntimeRef.current = undefined; promptSubmittedRuntimeRef.current = undefined;
completionHandledRef.current = false; completionHandledRef.current = false;
runStartedAtRef.current = undefined; runStartedAtRef.current = undefined;
@@ -474,7 +646,10 @@ export default function QuickPane() {
setCatalogError(undefined); setCatalogError(undefined);
setCatalogNotice(undefined); setCatalogNotice(undefined);
setModels([]); setModels([]);
if (clearDraft) setRequest(""); if (clearDraft) {
inputSelectionRef.current = undefined;
setRequest("");
}
}, []); }, []);
const replaceChat = useCallback( const replaceChat = useCallback(
@@ -537,6 +712,10 @@ export default function QuickPane() {
void loadSettingsAndPrepare(); void loadSettingsAndPrepare();
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
if (resizeRetryTimeoutRef.current !== undefined)
window.clearTimeout(resizeRetryTimeoutRef.current);
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
selectionRestorePendingRef.current = false;
preparationGenerationRef.current += 1; preparationGenerationRef.current += 1;
const current = runtimeRef.current; const current = runtimeRef.current;
if (current) void closeRuntime(current.runtimeId).catch(() => undefined); if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
@@ -553,13 +732,16 @@ export default function QuickPane() {
if (!pane || !exchange) return; if (!pane || !exchange) return;
const resizeToContent = () => { const resizeToContent = () => {
window.cancelAnimationFrame(resizeFrameRef.current ?? 0); window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
if (modelMenuOpenRef.current) return;
resizeFrameRef.current = window.requestAnimationFrame(() => { resizeFrameRef.current = window.requestAnimationFrame(() => {
if (modelMenuOpenRef.current) return;
const visibleChildren = [...exchange.children].filter( const visibleChildren = [...exchange.children].filter(
(child) => !child.classList.contains("sr-only"), (child) => !child.classList.contains("sr-only"),
); );
const childHeight = visibleChildren.reduce( const childHeight = visibleChildren.reduce(
(total, child) => (total, child) =>
total + Math.max(child.scrollHeight, child.getBoundingClientRect().height), total +
Math.max(child.scrollHeight, child.getBoundingClientRect().height),
0, 0,
); );
const styles = window.getComputedStyle(exchange); const styles = window.getComputedStyle(exchange);
@@ -573,12 +755,12 @@ export default function QuickPane() {
520, 520,
Math.max( Math.max(
240, 240,
Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight), Math.ceil(
pane.clientHeight - exchange.clientHeight + contentHeight,
),
), ),
); );
if (height === requestedPaneHeightRef.current) return; requestPaneResize(height);
requestedPaneHeightRef.current = height;
void invoke("resize_quick_pane", { height }).catch(() => undefined);
}); });
}; };
if (typeof ResizeObserver === "undefined") { if (typeof ResizeObserver === "undefined") {
@@ -594,7 +776,18 @@ export default function QuickPane() {
observer.disconnect(); observer.disconnect();
window.cancelAnimationFrame(resizeFrameRef.current ?? 0); window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
}; };
}, [activity, catalogError, catalogNotice, error, escalation, messages, notice, responseComplete]); }, [
activity,
catalogError,
catalogNotice,
error,
escalation,
messages,
modelMenuOpen,
notice,
requestPaneResize,
responseComplete,
]);
useEffect(() => { useEffect(() => {
if (!runActive) return; if (!runActive) return;
@@ -631,6 +824,11 @@ export default function QuickPane() {
}, },
); );
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") inputFocusIntentRef.current = "keyboard";
if (catalogOverlayActive) {
event.preventDefault();
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "n") { if (event.ctrlKey && event.key.toLowerCase() === "n") {
event.preventDefault(); event.preventDefault();
void replaceChat(true, true); void replaceChat(true, true);
@@ -658,7 +856,13 @@ export default function QuickPane() {
void settingsListener.then((unlisten) => unlisten()); void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten());
}; };
}, [dismissPane, prepareRuntime, renewChatOnFocus, replaceChat]); }, [
catalogOverlayActive,
dismissPane,
prepareRuntime,
renewChatOnFocus,
replaceChat,
]);
useEffect(() => { useEffect(() => {
if (!runtime || !requestActive) return; if (!runtime || !requestActive) return;
@@ -673,14 +877,18 @@ export default function QuickPane() {
if (!active || chatGeneration !== chatGenerationRef.current) return; if (!active || chatGeneration !== chatGenerationRef.current) return;
const text = currentTurnAssistantText( const text = currentTurnAssistantText(
snapshot, snapshot,
submittedPromptRequestsRef.current, submittedPromptTurnsRef.current,
); );
const result = visibleAssistantText(text); const result = visibleAssistantText(text);
const complete = const complete =
!snapshotIsActive(snapshot) && !snapshotIsActive(snapshot) &&
promptSubmittedRuntimeRef.current === runtime.runtimeId; promptSubmittedRuntimeRef.current === runtime.runtimeId;
if (complete) {
const timing = activeSubmittedTurnRef.current?.timing;
if (timing && !timing.completedAt) timing.completedAt = Date.now();
}
setMessages( setMessages(
transcriptMessages(snapshot, submittedPromptRequestsRef.current), transcriptMessages(snapshot, submittedPromptTurnsRef.current),
); );
setActivitySnapshot({ setActivitySnapshot({
activeTool: snapshot.runtime.activeTool, activeTool: snapshot.runtime.activeTool,
@@ -702,9 +910,11 @@ export default function QuickPane() {
setNotice(""); setNotice("");
const submitted = submittedRequestRef.current; const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) if (submitted?.runtimeId === runtime.runtimeId)
setRequest((current) => setRequest((current) => {
current === submitted.request ? "" : current, if (current !== submitted.request) return current;
); inputSelectionRef.current = undefined;
return "";
});
if (text.startsWith(ESCALATION_MARKER)) if (text.startsWith(ESCALATION_MARKER))
setEscalation(result || "Quick agent requested a full session."); setEscalation(result || "Quick agent requested a full session.");
window.requestAnimationFrame(focusInput); window.requestAnimationFrame(focusInput);
@@ -730,7 +940,6 @@ export default function QuickPane() {
}; };
}, [requestActive, runtime]); }, [requestActive, runtime]);
async function run() { async function run() {
if (submissionStartingRef.current || requestActiveRef.current) return; if (submissionStartingRef.current || requestActiveRef.current) return;
if (invalid || !request.trim()) if (invalid || !request.trim())
@@ -764,9 +973,13 @@ export default function QuickPane() {
const submittedRuntime = prepared.runtime; const submittedRuntime = prepared.runtime;
if (chatGeneration !== chatGenerationRef.current) return; if (chatGeneration !== chatGenerationRef.current) return;
submittedRequestRef.current = { const submittedAt = Date.now();
runtimeId: submittedRuntime.runtimeId, 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}`;
const submittedTurn = {
id: ++nextSubmittedTurnIdRef.current,
guardrails,
request: submittedRequest, request: submittedRequest,
timing: { startedAt: submittedAt },
}; };
promptSubmittedRuntimeRef.current = undefined; promptSubmittedRuntimeRef.current = undefined;
completionHandledRef.current = false; completionHandledRef.current = false;
@@ -777,7 +990,6 @@ export default function QuickPane() {
setExtension(undefined); setExtension(undefined);
setActivitySnapshot({ hasAssistantText: false }); setActivitySnapshot({ hasAssistantText: false });
setNotice(""); setNotice("");
const submittedAt = Date.now();
lastUserQuestionAtRef.current = submittedAt; lastUserQuestionAtRef.current = submittedAt;
runStartedAtRef.current = submittedAt; runStartedAtRef.current = submittedAt;
setElapsedSeconds(0); setElapsedSeconds(0);
@@ -795,14 +1007,18 @@ export default function QuickPane() {
level: thinkingLevel, level: thinkingLevel,
}); });
if (chatGeneration !== chatGenerationRef.current) return; 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", { await invoke("submit_prompt", {
agentId: submittedRuntime.agentId, agentId: submittedRuntime.agentId,
message: guardrails, message: guardrails,
}); });
if (chatGeneration === chatGenerationRef.current) if (chatGeneration !== chatGenerationRef.current) return;
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId; submittedRequestRef.current = {
runtimeId: submittedRuntime.runtimeId,
request: submittedRequest,
};
submittedPromptTurnsRef.current.push(submittedTurn);
activeSubmittedTurnRef.current = submittedTurn;
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
} catch (reason) { } catch (reason) {
if (chatGeneration === chatGenerationRef.current) { if (chatGeneration === chatGenerationRef.current) {
requestActiveRef.current = false; requestActiveRef.current = false;
@@ -838,17 +1054,25 @@ export default function QuickPane() {
request.trim() || submittedRequestRef.current?.request || ""; request.trim() || submittedRequestRef.current?.request || "";
const canContinue = !invalid && !!handoffRequest; const canContinue = !invalid && !!handoffRequest;
const completedAssistantAnswer = const completedAssistantAnswer =
responseComplete && messages.some((message) => message.role === "assistant"); responseComplete &&
messages.some((message) => message.role === "assistant");
const completedAssistantMessageId = completedAssistantAnswer const completedAssistantMessageId = completedAssistantAnswer
? [...messages].reverse().find((message) => message.role === "assistant")?.id ? [...messages].reverse().find((message) => message.role === "assistant")
?.id
: undefined;
const streamingAssistantMessageId = runActive
? [...messages].reverse().find((message) => message.role === "assistant")
?.id
: undefined; : undefined;
const hasExchange = const hasExchange =
messages.length > 0 || messages.length > 0 ||
responseComplete || responseComplete ||
submittedRequestRef.current !== undefined; submittedRequestRef.current !== undefined;
const hasTranscript = messages.length > 0 || runActive || responseComplete;
const transcriptHandoff = messages const transcriptHandoff = messages
.map((message) => .map(
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`, (message) =>
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
) )
.join("\n\n"); .join("\n\n");
@@ -877,22 +1101,59 @@ export default function QuickPane() {
} }
} }
useEffect(() => {
if (!hasTranscript) return;
const transcript = transcriptRef.current;
if (!transcript) return;
transcriptFollowRef.current = true;
const trackTranscriptScroll = () => {
transcriptFollowRef.current =
transcript.scrollHeight -
transcript.clientHeight -
transcript.scrollTop <=
32;
};
transcript.addEventListener("scroll", trackTranscriptScroll);
return () =>
transcript.removeEventListener("scroll", trackTranscriptScroll);
}, [hasTranscript]);
useEffect(() => {
if (!hasTranscript || !transcriptFollowRef.current) return;
window.cancelAnimationFrame(transcriptScrollFrameRef.current ?? 0);
transcriptScrollFrameRef.current = window.requestAnimationFrame(() => {
if (!transcriptFollowRef.current) return;
const transcript = transcriptRef.current;
if (transcript) transcript.scrollTop = transcript.scrollHeight;
});
return () =>
window.cancelAnimationFrame(transcriptScrollFrameRef.current ?? 0);
}, [
activity,
catalogError,
catalogNotice,
error,
escalation,
extension,
hasTranscript,
messages,
notice,
responseComplete,
]);
return ( return (
<main className="quick-pane" ref={paneRef}> <main className="quick-pane" ref={paneRef}>
<div <div
className="quick-pane-drag-strip" className="quick-pane-drag-strip"
aria-hidden="true" aria-hidden="true"
onPointerDown={() => onPointerDown={() =>
void getCurrentWindow().startDragging().catch(() => undefined) void getCurrentWindow()
.startDragging()
.catch(() => undefined)
} }
/> />
<header className="quick-pane-toolbar"> <header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls"> <div className="quick-pane-primary-controls">
{catalogStatus === "loading" && (
<span className="quick-pane-model-loading" role="status">
Loading models…
</span>
)}
<ModelSelector <ModelSelector
className="quick-pane-model-selector" className="quick-pane-model-selector"
placement="down" placement="down"
@@ -901,7 +1162,11 @@ export default function QuickPane() {
provider: selectedModel.provider, provider: selectedModel.provider,
id: selectedModel.modelId, id: selectedModel.modelId,
}} }}
disabled={runActive || catalogStatus !== "ready"} disabled={
catalogOverlayActive || runActive || catalogStatus !== "ready"
}
onOpen={handleModelMenuOpen}
onClose={handleModelMenuClose}
onSelect={async (model) => { onSelect={async (model) => {
const identity = modelIdentity(model); const identity = modelIdentity(model);
if (!identity) return false; if (!identity) return false;
@@ -913,24 +1178,13 @@ export default function QuickPane() {
return true; return true;
}} }}
/> />
<label className="quick-pane-field quick-pane-thinking"> <ThinkingSelector
<span className="full-label">Thinking</span> className="quick-pane-thinking"
<span className="compact-label" aria-hidden="true"> model={selectedCatalogModel}
T value={thinkingLevel}
</span> disabled={catalogOverlayActive || runActive || chatReplacing}
<select onChange={setThinkingLevel}
aria-label="Thinking" />
value={thinkingLevel}
disabled={runActive || chatReplacing}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
{thinkingOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div> </div>
<div className="quick-pane-header-actions"> <div className="quick-pane-header-actions">
<button <button
@@ -938,6 +1192,7 @@ export default function QuickPane() {
type="button" type="button"
aria-label="Close Quick Pane" aria-label="Close Quick Pane"
title="Close Quick Pane (Esc)" title="Close Quick Pane (Esc)"
disabled={catalogOverlayActive}
onClick={dismissPane} onClick={dismissPane}
> >
<span className="full-label">Close</span> <span className="full-label">Close</span>
@@ -955,9 +1210,23 @@ export default function QuickPane() {
autoFocus autoFocus
ref={inputRef} ref={inputRef}
readOnly={runActive} readOnly={runActive}
disabled={catalogOverlayActive}
aria-busy={runActive} aria-busy={runActive}
value={request} value={request}
onChange={(event) => setRequest(event.currentTarget.value)} onPointerDown={() => {
inputFocusIntentRef.current = "pointer";
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
selectionRestoreFrameRef.current = undefined;
selectionRestorePendingRef.current = false;
}}
onFocus={handleInputFocus}
onBlur={(event) => recordInputSelection(event.currentTarget)}
onSelect={(event) => recordInputSelection(event.currentTarget)}
onInput={(event) => recordInputChangeSelection(event.currentTarget)}
onChange={(event) => {
recordInputChangeSelection(event.currentTarget);
setRequest(event.currentTarget.value);
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if ( if (
event.key === "Enter" && event.key === "Enter" &&
@@ -970,7 +1239,10 @@ export default function QuickPane() {
}} }}
placeholder="Ask a quick question or give a quick instruction" placeholder="Ask a quick question or give a quick instruction"
/> />
<div className="quick-pane-composer-actions" aria-label="Quick chat actions"> <div
className="quick-pane-composer-actions"
aria-label="Quick chat actions"
>
{requestActive && ( {requestActive && (
<button <button
className="quick-pane-action" className="quick-pane-action"
@@ -1019,7 +1291,7 @@ export default function QuickPane() {
aria-label="Quick exchange" aria-label="Quick exchange"
ref={exchangeRef} ref={exchangeRef}
> >
{(messages.length > 0 || runActive || responseComplete) && ( {hasTranscript && (
<div <div
className="quick-pane-transcript" className="quick-pane-transcript"
ref={transcriptRef} ref={transcriptRef}
@@ -1028,8 +1300,15 @@ export default function QuickPane() {
aria-live="polite" aria-live="polite"
aria-busy={runActive} aria-busy={runActive}
> >
{messages.map((message) => {messages.map((message) => {
message.role === "user" ? ( const elapsed =
message.timing && (message.timing.completedAt || runActive)
? elapsedLabel(
message.timing.startedAt,
message.timing.completedAt,
)
: undefined;
return message.role === "user" ? (
<div className="quick-pane-user-row" key={message.id}> <div className="quick-pane-user-row" key={message.id}>
<article className="quick-pane-user-message"> <article className="quick-pane-user-message">
<span className="message-author">You</span> <span className="message-author">You</span>
@@ -1047,6 +1326,16 @@ export default function QuickPane() {
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.markdown} {message.markdown}
</ReactMarkdown> </ReactMarkdown>
{elapsed && (
<span className="quick-pane-assistant-elapsed">
{elapsed}
</span>
)}
{message.id === streamingAssistantMessageId && activity && (
<span className="quick-pane-assistant-activity">
{activity}
</span>
)}
{message.id === completedAssistantMessageId && {message.id === completedAssistantMessageId &&
!escalation && !escalation &&
canContinue && ( canContinue && (
@@ -1057,9 +1346,9 @@ export default function QuickPane() {
</footer> </footer>
)} )}
</article> </article>
), );
)} })}
{activity && ( {activity && !streamingAssistantMessageId && (
<div className="quick-pane-activity" aria-hidden="true"> <div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots"> <span className="typing-dots">
<i /> <i />
@@ -1094,7 +1383,7 @@ export default function QuickPane() {
)} )}
</aside> </aside>
)} )}
{error && ( {error && initialCatalogResolved && (
<aside className="quick-pane-notice error" role="alert"> <aside className="quick-pane-notice error" role="alert">
<p>{error}</p> <p>{error}</p>
{settingsLoaded ? ( {settingsLoaded ? (
@@ -1115,7 +1404,7 @@ export default function QuickPane() {
)} )}
</aside> </aside>
)} )}
{catalogError && ( {catalogError && initialCatalogResolved && (
<aside className="quick-pane-notice error" role="alert"> <aside className="quick-pane-notice error" role="alert">
<p>{catalogError}</p> <p>{catalogError}</p>
<button <button
@@ -1126,7 +1415,7 @@ export default function QuickPane() {
</button> </button>
</aside> </aside>
)} )}
{notice && ( {notice && initialCatalogResolved && (
<p className="quick-pane-system-notice" role="status"> <p className="quick-pane-system-notice" role="status">
{notice} {notice}
</p> </p>
@@ -1138,6 +1427,35 @@ export default function QuickPane() {
)} )}
<ExtensionDialog extension={extension} onRespond={respond} /> <ExtensionDialog extension={extension} onRespond={respond} />
</section> </section>
{catalogOverlayActive && (
<aside
className="quick-pane-catalog-overlay"
role={catalogOverlayError ? "alertdialog" : "dialog"}
aria-modal="true"
aria-label="Model catalog"
>
{catalogOverlayError ? (
<>
<p>{catalogOverlayError}</p>
<button
type="button"
onClick={() => {
if (catalogError)
void prepareRuntime(selectedModelRef.current);
else void loadSettingsAndPrepare();
}}
>
Retry
</button>
</>
) : (
<>
<span className="quick-pane-catalog-spinner" aria-hidden="true" />
<span role="status">Loading models…</span>
</>
)}
</aside>
)}
</main> </main>
); );
} }
+56 -4
View File
@@ -18,6 +18,7 @@ body {
} }
.quick-pane { .quick-pane {
position: relative;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -53,7 +54,12 @@ body {
cursor: move; cursor: move;
touch-action: none; touch-action: none;
border-bottom: 1px solid #70522f; border-bottom: 1px solid #70522f;
background: linear-gradient(90deg, transparent, rgba(240, 163, 71, 0.14), transparent); background: linear-gradient(
90deg,
transparent,
rgba(240, 163, 71, 0.14),
transparent
);
} }
.quick-pane-drag-strip::after { .quick-pane-drag-strip::after {
@@ -107,13 +113,43 @@ body {
text-transform: uppercase; text-transform: uppercase;
} }
.quick-pane-model-loading { .quick-pane-catalog-overlay {
flex: 0 0 auto; position: absolute;
z-index: 10;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
color: #f1e4d1; color: #f1e4d1;
font-size: 11px; background: rgba(34, 34, 34, 0.98);
border-radius: inherit;
font-weight: 750; font-weight: 750;
} }
.quick-pane-catalog-overlay p {
max-width: 440px;
margin: 0;
text-align: center;
}
.quick-pane-catalog-spinner {
width: 22px;
height: 22px;
border: 3px solid rgba(240, 163, 71, 0.28);
border-top-color: #f0a347;
border-radius: 50%;
animation: quick-pane-catalog-spin 0.8s linear infinite;
}
@keyframes quick-pane-catalog-spin {
to {
transform: rotate(360deg);
}
}
.quick-pane .model-trigger { .quick-pane .model-trigger {
height: 27px; height: 27px;
border: 0; border: 0;
@@ -299,6 +335,22 @@ body {
margin-bottom: 0; margin-bottom: 0;
} }
.quick-pane-assistant-elapsed,
.quick-pane-assistant-activity {
display: block;
margin-top: 6px;
color: #96918b;
font-size: 10px;
}
.quick-pane-assistant-elapsed {
font-variant-numeric: tabular-nums;
}
.quick-pane-assistant-activity {
color: #c5beb5;
}
.quick-pane-assistant-footer { .quick-pane-assistant-footer {
margin-top: 8px; margin-top: 8px;
border-top: 1px solid #484848; border-top: 1px solid #484848;