feat(quick-pane): add chat-style exchange

This commit is contained in:
2026-08-26 15:26:03 +02:00
parent 0ec7d224a2
commit d34099771c
6 changed files with 2655 additions and 287 deletions
+46 -15
View File
@@ -9,9 +9,12 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async ()
);
assert.match(source, /get_session_runtime_snapshot/);
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(source, /\{displayedAnswer && <pre>\{displayedAnswer\}<\/pre>\}/);
assert.match(source, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/);
assert.match(source, /\{canContinue && \(/);
assert.match(source, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
assert.match(source, /className="quick-pane-notice escalation"/);
assert.match(
source,
/<button type="button" onClick=\{continueFull\}>\s*Continue\s*<\/button>/,
);
});
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
@@ -27,7 +30,10 @@ test("main Settings uses shared catalog selectors with provider derived from mod
"utf8",
),
]);
assert.match(catalog, /invoke<\{ models\?: Model\[\] \}>\("get_model_catalog"\)/);
assert.match(
catalog,
/invoke<\{ models\?: Model\[\] \}>\("get_model_catalog"\)/,
);
assert.match(app, /getModelCatalog\(\)/);
assert.match(app, /Quick model[\s\S]*?<select/);
assert.match(app, /Follow-up model[\s\S]*?<select/);
@@ -42,6 +48,8 @@ test("main Settings uses shared catalog selectors with provider derived from mod
assert.doesNotMatch(pane, /Save Quick Pane settings/);
assert.match(pane, /isStreaming === false/);
assert.match(pane, /current === submitted\.request \? "" : current/);
assert.match(pane, /readOnly=\{runActive\}/);
assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/);
assert.match(pane, /\.slice\(0, 5\)/);
assert.match(pane, /aria-label="Answer history"/);
assert.doesNotMatch(pane, /localStorage|sessionStorage|save_ui_state/);
@@ -61,7 +69,10 @@ test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on
assert.match(pane, /modelId: selectedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/);
assert.match(catalog, /JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/);
assert.match(
catalog,
/JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/,
);
assert.match(catalog, /JSON\.parse\(value\)/);
});
@@ -79,7 +90,8 @@ test("Quick Pane routes extension responses through existing bridge command", as
});
test("Quick Pane uses native dimensions and request-first compact layout", async () => {
const [source, stylesheet, configSource, nativeSource, capabilitySource] = await Promise.all([
const [source, stylesheet, configSource, nativeSource, capabilitySource] =
await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
@@ -88,13 +100,21 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
new URL("../ui/src/quick-pane/quick-pane.css", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src-tauri/tauri.conf.json", import.meta.url), "utf8"),
readFile(
new URL("../ui/src-tauri/tauri.conf.json", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
readFile(new URL("../ui/src-tauri/capabilities/default.json", import.meta.url), "utf8"),
readFile(
new URL("../ui/src-tauri/capabilities/default.json", import.meta.url),
"utf8",
),
]);
const config = JSON.parse(configSource);
const capability = JSON.parse(capabilitySource);
const quickPane = config.app.windows.find((window) => window.label === "quick-pane");
const quickPane = config.app.windows.find(
(window) => window.label === "quick-pane",
);
assert.deepEqual(
{
@@ -107,12 +127,16 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
assert.ok(capability.windows.includes("quick-pane"));
assert.match(nativeSource, /WindowEvent::CloseRequested/);
assert.match(nativeSource, /api\.prevent_close\(\)/);
assert.match(nativeSource, /WebviewWindowBuilder::from_config\(app, config\)/);
assert.match(
nativeSource,
/WebviewWindowBuilder::from_config\(app, config\)/,
);
assert.match(nativeSource, /get_or_create_quick_pane/);
assert.doesNotMatch(source, /onCloseRequested/);
assert.match(source, /className="quick-pane-request"/);
assert.match(source, /className="quick-pane-output"/);
assert.match(source, /<footer className="quick-pane-controls">/);
assert.match(source, /className="quick-pane-exchange"/);
assert.match(source, /className="quick-pane-composer"/);
assert.match(source, /className="quick-pane-send"/);
assert.match(source, /ref=\{inputRef\}/);
assert.match(source, /focusInput\(\)/);
assert.doesNotMatch(source, /if \(!event\.payload\)[\s\S]*?hide\(/);
@@ -121,9 +145,16 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
/event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
);
assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/);
assert.match(stylesheet, /\.quick-pane-request[\s\S]*?flex: 1 1 auto/);
assert.match(stylesheet, /\.quick-pane-output[\s\S]*?max-height: 108px[\s\S]*?overflow: auto/);
assert.match(stylesheet, /\.quick-pane-controls[\s\S]*?display: flex/);
assert.match(
stylesheet,
/\.quick-pane-exchange[\s\S]*?flex: 1 1 auto[\s\S]*?overflow-y: auto/,
);
assert.match(
stylesheet,
/\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/,
);
assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/);
assert.match(stylesheet, /@media \(max-width: 400px\)/);
assert.match(stylesheet, /#f0a347/);
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
});
+1461 -7
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -23,7 +23,9 @@
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
+413 -9
View File
@@ -112,8 +112,20 @@ describe("QuickPane", () => {
screen.getByPlaceholderText(/Ask a quick question/),
"Inspect",
);
await user.click(screen.getByRole("button", { name: "Ask" }));
await screen.findByText(/Quick request running/);
await user.click(screen.getByRole("button", { name: "Send request" }));
const exchange = screen.getByRole("region", { name: "Quick exchange" });
expect(within(exchange).getByText("Inspect")).toBeInTheDocument();
const visualActivity = within(exchange).getByText(/Thinking · \d+s/);
expect(visualActivity.closest(".quick-pane-activity")).toHaveAttribute(
"aria-hidden",
"true",
);
const activityAnnouncement = within(exchange).getByText("Thinking", {
selector: ".quick-pane-activity-announcement",
});
expect(activityAnnouncement).toHaveAttribute("aria-live", "polite");
expect(activityAnnouncement).toHaveTextContent("Thinking");
expect(activityAnnouncement).not.toHaveTextContent("·");
await act(async () => mocks.focusHandler?.({ payload: true }));
expect(
@@ -167,7 +179,7 @@ describe("QuickPane", () => {
render(<QuickPane />);
const input = await screen.findByPlaceholderText(/Ask a quick question/);
fireEvent.change(input, { target: { value: "Inspect once" } });
const ask = screen.getByRole("button", { name: "Ask" });
const ask = screen.getByRole("button", { name: "Send request" });
fireEvent.click(ask);
fireEvent.click(ask);
@@ -176,7 +188,11 @@ describe("QuickPane", () => {
([command]) => command === "create_quick_runtime",
),
).toHaveLength(1);
expect(screen.getByRole("button", { name: "Starting…" })).toBeDisabled();
expect(
screen.getByRole("button", { name: "Starting request" }),
).toBeDisabled();
expect(input).toHaveFocus();
expect(input).toHaveAttribute("readonly");
await act(async () => {
resolveCreate?.({
@@ -186,6 +202,65 @@ describe("QuickPane", () => {
});
});
it("mounts an empty busy live region before the first snapshot and keeps it through completion", async () => {
const user = userEvent.setup();
let resolveSnapshot!: (value: ReturnType<typeof snapshot>) => void;
const firstSnapshot = new Promise<ReturnType<typeof snapshot>>(
(resolve) => {
resolveSnapshot = resolve;
},
);
mocks.invoke.mockImplementation((command: string) => {
switch (command) {
case "load_ui_state":
return Promise.resolve(uiState);
case "get_model_catalog":
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
case "create_quick_runtime":
return Promise.resolve({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
case "get_session_runtime_snapshot":
return firstSnapshot;
default:
return Promise.resolve(undefined);
}
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await user.type(input, "Inspect this");
await user.click(screen.getByRole("button", { name: "Send request" }));
const assistantResponse = screen.getByRole("region", {
name: "Assistant response",
});
expect(assistantResponse).toBeEmptyDOMElement();
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
agentId: "agent-1",
message: expect.any(String),
}),
);
await act(async () => {
resolveSnapshot(snapshot("Final answer", false));
await firstSnapshot;
});
expect(await screen.findByText("Final answer")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Assistant response" })).toBe(
assistantResponse,
);
expect(assistantResponse).toHaveAttribute("aria-busy", "false");
expect(
within(assistantResponse).getByText("Response complete."),
).toBeInTheDocument();
});
it("clears only the submitted request after a completed non-streaming answer", async () => {
const user = userEvent.setup();
let currentSnapshot = snapshot("Partial answer", true);
@@ -211,20 +286,44 @@ describe("QuickPane", () => {
render(<QuickPane />);
const input = await screen.findByPlaceholderText(/Ask a quick question/);
await user.type(input, "Inspect this");
await user.click(screen.getByRole("button", { name: "Ask" }));
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(await screen.findByText("Partial answer")).toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Quick exchange" })).getByText(
"Inspect this",
),
).toBeInTheDocument();
const assistantResponse = screen.getByRole("region", {
name: "Assistant response",
});
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
expect(
within(assistantResponse).queryByText("Response complete."),
).toBeNull();
expect(input).toHaveValue("Inspect this");
expect(input).toHaveAttribute("readonly");
expect(input).toHaveFocus();
currentSnapshot = snapshot("Final answer", false);
expect(
await screen.findByText("Final answer", {}, { timeout: 2_000 }),
).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
expect(assistantResponse).toHaveAttribute("aria-busy", "false");
expect(
within(assistantResponse).getByText("Response complete."),
).toBeInTheDocument();
expect(input).not.toHaveAttribute("readonly");
expect(input).toHaveFocus();
await user.type(input, "Next draft");
await new Promise((resolve) => window.setTimeout(resolve, 900));
expect(input).toHaveValue("Next draft");
expect(
within(assistantResponse).getByText("Response complete."),
).toBeInTheDocument();
});
it("keeps five in-memory answers and history selection preserves current answer", async () => {
@@ -263,7 +362,7 @@ describe("QuickPane", () => {
const input = await screen.findByPlaceholderText(/Ask a quick question/);
for (let index = 1; index <= 6; index += 1) {
await user.type(input, `Request ${index}`);
await user.click(screen.getByRole("button", { name: "Ask" }));
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
}
@@ -277,17 +376,320 @@ describe("QuickPane", () => {
within(history).getByRole("option", { name: "Request 2" }),
).toBeInTheDocument();
await user.type(input, "Composer draft");
await user.selectOptions(history, "runtime-2");
expect(screen.getByText("Answer 2")).toBeInTheDocument();
const exchange = screen.getByRole("region", { name: "Quick exchange" });
expect(within(exchange).getByText("Request 2")).toBeInTheDocument();
expect(within(exchange).getByText("Answer 2")).toBeInTheDocument();
expect(screen.queryByText("Answer 6")).toBeNull();
expect(
within(exchange).getByRole("region", { name: "Assistant response" }),
).toHaveAttribute("aria-live", "off");
expect(input).toHaveValue("Composer draft");
await user.selectOptions(history, "");
expect(screen.getByText("Answer 6")).toBeInTheDocument();
expect(within(exchange).getByText("Request 6")).toBeInTheDocument();
expect(within(exchange).getByText("Answer 6")).toBeInTheDocument();
expect(
within(exchange).getByRole("region", { name: "Assistant response" }),
).toHaveAttribute("aria-live", "polite");
expect(input).toHaveValue("Composer draft");
expect(mocks.invoke).not.toHaveBeenCalledWith(
"save_ui_state",
expect.anything(),
);
});
it("suppresses partial escalation prefixes and delays its notice until completion", async () => {
const user = userEvent.setup();
let currentSnapshot = snapshot("", true);
mocks.invoke.mockImplementation((command: string) => {
switch (command) {
case "load_ui_state":
return Promise.resolve(uiState);
case "get_model_catalog":
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
case "create_quick_runtime":
return Promise.resolve({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
case "get_session_runtime_snapshot":
return Promise.resolve(currentSnapshot);
default:
return Promise.resolve(undefined);
}
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await user.type(input, "Change files");
await user.click(screen.getByRole("button", { name: "Send request" }));
const assistantResponse = screen.getByRole("region", {
name: "Assistant response",
});
const prefixes = ["[", "[ESCAL", "[ESCALATE]"];
for (const [index, prefix] of prefixes.entries()) {
currentSnapshot = {
...snapshot(prefix, true),
runtime: { activeTool: `functions.prefix_${index + 1}` },
};
expect(
await screen.findByText(
`Prefix ${index + 1}`,
{ selector: ".quick-pane-activity > span:last-child" },
{ timeout: 2_000 },
),
).toBeInTheDocument();
expect(assistantResponse).toBeEmptyDOMElement();
expect(
screen.queryByText("Continue in full session"),
).not.toBeInTheDocument();
}
currentSnapshot = snapshot("[ESCALATE]Needs full session.", true);
expect(
await within(assistantResponse).findByText(
"Needs full session.",
{},
{ timeout: 2_000 },
),
).toBeInTheDocument();
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
expect(
screen.queryByText("Continue in full session"),
).not.toBeInTheDocument();
currentSnapshot = snapshot("[ESCALATE]Needs full session.", false);
await waitFor(
() => expect(assistantResponse).toHaveAttribute("aria-busy", "false"),
{ timeout: 2_000 },
);
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
expect(assistantResponse).toHaveAttribute("aria-busy", "false");
expect(
within(assistantResponse).getAllByText("Response complete."),
).toHaveLength(1);
expect(screen.getByText("Continue in full session")).toBeInTheDocument();
});
it("never renders or stores a completed partial escalation marker", async () => {
const user = userEvent.setup();
mocks.invoke.mockImplementation((command: string) => {
switch (command) {
case "load_ui_state":
return Promise.resolve(uiState);
case "get_model_catalog":
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
case "create_quick_runtime":
return Promise.resolve({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
case "get_session_runtime_snapshot":
return Promise.resolve(snapshot("[ESCAL", false));
default:
return Promise.resolve(undefined);
}
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await user.type(input, "Needs review");
await user.click(screen.getByRole("button", { name: "Send request" }));
const assistantResponse = screen.getByRole("region", {
name: "Assistant response",
});
await waitFor(
() => expect(assistantResponse).toHaveAttribute("aria-busy", "false"),
{ timeout: 2_000 },
);
expect(assistantResponse).not.toHaveTextContent("[ESCAL");
expect(screen.queryByText("Continue in full session")).toBeNull();
expect(
screen.getByRole("combobox", { name: "Answer history" }),
).toBeInTheDocument();
});
it("keeps runtime error and extension visible while viewing history", async () => {
const user = userEvent.setup();
let runtimeCount = 0;
let runtimeFailed = false;
mocks.invoke.mockImplementation(
(command: string, payload?: { runtimeId?: string }) => {
switch (command) {
case "load_ui_state":
return Promise.resolve(uiState);
case "get_model_catalog":
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
case "create_quick_runtime":
runtimeCount += 1;
return Promise.resolve({
runtime: {
agentId: `agent-${runtimeCount}`,
runtimeId: `runtime-${runtimeCount}`,
},
});
case "get_session_runtime_snapshot":
if (payload?.runtimeId === "runtime-1")
return Promise.resolve(snapshot("First answer", false));
return Promise.resolve({
runtime: {
activeTool: "functions.read",
error: runtimeFailed
? { message: "Runtime disconnected." }
: undefined,
},
state: { data: { isStreaming: true } },
transcript: { data: { messages: [] } },
extensions: [
{
id: "permission-1",
method: "confirm",
title: "Run guarded command",
message: "Approval needed",
},
],
});
default:
return Promise.resolve(undefined);
}
},
);
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await user.type(input, "First request");
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(
await screen.findByText("First answer", {}, { timeout: 2_000 }),
).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue(""));
const history = screen.getByRole("combobox", { name: "Answer history" });
await user.type(input, "Active request");
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(
await screen.findByRole("dialog", { name: "Review request" }),
).toBeInTheDocument();
expect(history).toBeDisabled();
expect(history).toHaveValue("");
runtimeFailed = true;
expect(
await screen.findByText("Runtime disconnected.", {}, { timeout: 2_000 }),
).toBeInTheDocument();
await waitFor(() => expect(history).not.toBeDisabled());
await user.selectOptions(history, "runtime-1");
const exchange = screen.getByRole("region", { name: "Quick exchange" });
expect(within(exchange).getByText("First request")).toBeInTheDocument();
expect(within(exchange).getByText("First answer")).toBeInTheDocument();
expect(within(exchange).getByRole("alert")).toHaveTextContent(
"Runtime disconnected.",
);
expect(within(exchange).getByRole("status")).toHaveTextContent(
"Quick runtime failed.",
);
expect(
within(exchange).getByRole("dialog", { name: "Review request" }),
).toBeInTheDocument();
expect(screen.getByText("Run guarded command")).toBeInTheDocument();
expect(screen.getByText("Approval needed")).toBeInTheDocument();
});
it("renders safe GFM Markdown and reports active tools without tool cards", async () => {
const user = userEvent.setup();
let currentSnapshot: ReturnType<typeof snapshot> & {
runtime: { activeTool?: string };
} = {
...snapshot("", true),
runtime: { activeTool: "functions.grep" },
};
mocks.invoke.mockImplementation((command: string) => {
switch (command) {
case "load_ui_state":
return Promise.resolve(uiState);
case "get_model_catalog":
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
case "create_quick_runtime":
return Promise.resolve({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
case "get_session_runtime_snapshot":
return Promise.resolve(currentSnapshot);
default:
return Promise.resolve(undefined);
}
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
await user.type(input, "Explain output");
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(
await screen.findByText("Searching files", {
selector: ".quick-pane-activity > span:last-child",
}),
).toBeInTheDocument();
currentSnapshot = snapshot(
"## Result\n\n- item\n\n`const safe = true`\n\n[unsafe](javascript:alert(1))\n\n<img src=x onerror=alert(1)>",
false,
);
expect(
await screen.findByRole(
"heading",
{ name: "Result" },
{ timeout: 2_000 },
),
).toBeInTheDocument();
expect(screen.getByText("const safe = true")).toBeInTheDocument();
expect(screen.getByText("unsafe").closest("a")).toHaveAttribute("href", "");
expect(document.querySelector("img")).toBeNull();
expect(document.querySelector("details.tool-result")).toBeNull();
});
it("uses Enter to send and leaves Shift+Enter available for newlines", async () => {
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog")
return Promise.resolve({
models: [{ provider: "provider", id: "quick/model" }],
});
if (command === "create_quick_runtime")
return Promise.resolve({
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
});
if (command === "get_session_runtime_snapshot")
return Promise.resolve(snapshot("", true));
return Promise.resolve(undefined);
});
render(<QuickPane />);
const input = await screen.findByRole("textbox", { name: "Request" });
fireEvent.change(input, { target: { value: "Inspect" } });
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_quick_runtime",
expect.anything(),
);
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("create_quick_runtime", {
worktreePath: "/repo",
}),
);
});
it("leaves close interception native while preserving close and Escape hiding", async () => {
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
@@ -300,7 +702,9 @@ describe("QuickPane", () => {
await screen.findByPlaceholderText(/Ask a quick question/);
expect(mocks.onCloseRequested).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Close" }));
await userEvent.click(
screen.getByRole("button", { name: "Close Quick Pane" }),
);
fireEvent.keyDown(window, { key: "Escape" });
expect(mocks.hide).toHaveBeenCalledTimes(2);
});
+290 -98
View File
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog";
import {
catalogModels,
@@ -20,6 +22,8 @@ import type {
UiState,
} from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]";
const defaults: QuickPaneSettings = {
workspace: "",
provider: "",
@@ -34,6 +38,10 @@ const defaults: QuickPaneSettings = {
type Runtime = { agentId: string; runtimeId: string };
type HistoryEntry = { id: string; request: string; answer: string };
type ActivitySnapshot = {
activeTool?: string;
hasAssistantText: boolean;
};
function historyLabel(request: string) {
const concise = request.replace(/\s+/g, " ").trim();
@@ -76,10 +84,29 @@ function assistantText(snapshot: RuntimeSnapshot) {
.join("\n");
}
function toolActivityLabel(tool: string) {
const segments = tool.split(".");
const name = segments[segments.length - 1]?.replace(/[_-]+/g, " ") ?? tool;
const labels: Record<string, string> = {
bash: "Running command",
edit: "Editing file",
find: "Finding files",
grep: "Searching files",
ls: "Listing files",
read: "Reading file",
write: "Writing file",
};
return (
labels[name.toLowerCase()] ??
`${name.charAt(0).toUpperCase()}${name.slice(1)}`
);
}
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 [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
@@ -87,22 +114,29 @@ export default function QuickPane() {
provider: "",
modelId: "",
});
const [status, setStatus] = useState("Loading Quick Pane settings…");
const [notice, setNotice] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>();
const [catalogError, setCatalogError] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
const [failedRuntimeId, setFailedRuntimeId] = useState<string>();
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
const [activitySnapshot, setActivitySnapshot] = useState<ActivitySnapshot>({
hasAssistantText: false,
});
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const runtimeRef = useRef<Runtime | undefined>(undefined);
const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined
>(undefined);
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo(
@@ -113,11 +147,32 @@ export default function QuickPane() {
selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel)
: "";
const canContinue = !!escalation || (!!runtime && !!error);
const displayedAnswer = selectedHistoryId
? (history.find((entry) => entry.id === selectedHistoryId)?.answer ??
answer)
: answer;
const runActive =
submissionStarting ||
(!!runtime &&
completedRuntimeId !== runtime.runtimeId &&
failedRuntimeId !== runtime.runtimeId);
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
? toolActivityLabel(activitySnapshot.activeTool)
: runActive && activitySnapshot.hasAssistantText
? "Answering"
: runActive
? "Thinking"
: "";
const activity =
activityPhase === "Thinking"
? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase;
const dismissPane = () => getCurrentWindow().hide();
const focusInput = () => inputRef.current?.focus();
@@ -149,7 +204,7 @@ export default function QuickPane() {
provider: nextSettings.provider,
modelId: nextSettings.model,
});
setStatus("");
setNotice("");
if (active) await loadModelCatalog();
})();
return () => {
@@ -166,6 +221,22 @@ export default function QuickPane() {
if (settingsLoaded) focusInput();
}, [settingsLoaded]);
useEffect(() => {
if (!runActive) return;
const updateElapsed = () =>
setElapsedSeconds(
Math.max(
0,
Math.floor(
(Date.now() - (runStartedAtRef.current ?? Date.now())) / 1000,
),
),
);
updateElapsed();
const interval = window.setInterval(updateElapsed, 1_000);
return () => window.clearInterval(interval);
}, [runActive]);
useEffect(() => {
const paneWindow = getCurrentWindow();
const settingsListener = listen<QuickPaneSettings>(
@@ -209,21 +280,36 @@ export default function QuickPane() {
);
if (!active) return;
const text = assistantText(snapshot);
if (text) {
setAnswer(text);
if (text.includes("[ESCALATE]")) {
setEscalation(
text.replace("[ESCALATE]", "").trim() ||
"Quick agent requested a full session.",
);
const responseComplete =
snapshot.state?.data?.isStreaming === false &&
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;
setActivitySnapshot({
activeTool: snapshot.runtime.activeTool,
hasAssistantText: !!text,
});
if (awaitingEscalationMarker) {
setAnswer("");
} else if (text) {
setAnswer(result);
}
if (escalated && responseComplete) {
setEscalation(result || "Quick agent requested a full session.");
}
if (
snapshot.state?.data?.isStreaming === false &&
responseComplete &&
completedRuntimeRef.current !== runtime.runtimeId
) {
completedRuntimeRef.current = runtime.runtimeId;
setCompletedRuntimeId(runtime.runtimeId);
setStatus("Quick answer ready.");
setNotice("");
const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) {
setRequest((current) =>
@@ -234,17 +320,18 @@ export default function QuickPane() {
{
id: runtime.runtimeId,
request: submitted.request,
answer: text,
answer: result,
},
...current.filter((entry) => entry.id !== runtime.runtimeId),
].slice(0, 5),
);
}
}
window.requestAnimationFrame(focusInput);
}
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
setFailedRuntimeId(runtime.runtimeId);
setEscalation(
"Quick runtime failed. Continue in a full session if needed.",
);
@@ -268,21 +355,23 @@ export default function QuickPane() {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
setStatus(
"Quick time budget reached. Showing partial result if available.",
);
setFailedRuntimeId(runtime.runtimeId);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
async function run() {
if (submissionStartingRef.current) return;
if (submissionStartingRef.current || runActive) return;
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
runStartedAtRef.current = Date.now();
setElapsedSeconds(0);
try {
const previous = runtimeRef.current;
if (previous)
@@ -291,15 +380,17 @@ export default function QuickPane() {
}).catch(() => undefined);
runtimeRef.current = undefined;
submittedRequestRef.current = undefined;
promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined;
setRuntime(undefined);
setCompletedRuntimeId(undefined);
setSelectedHistoryId("");
setFailedRuntimeId(undefined);
setError(undefined);
setAnswer("");
setEscalation(undefined);
setExtension(undefined);
setStatus("Starting advisory quick agent…");
setActivitySnapshot({ hasAssistantText: false });
setNotice("");
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
{
@@ -326,17 +417,15 @@ export default function QuickPane() {
agentId: result.runtime.agentId,
message: guardrails,
});
if (completedRuntimeRef.current !== result.runtime.runtimeId) {
setStatus(
"Quick request running. Advisory limits are not trusted enforcement.",
);
}
promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
} catch (reason) {
setError(`Could not start quick request: ${String(reason)}`);
setStatus("");
setFailedRuntimeId(runtimeRef.current?.runtimeId);
} finally {
submissionStartingRef.current = false;
setSubmissionStarting(false);
focusInput();
window.requestAnimationFrame(focusInput);
}
}
@@ -356,7 +445,7 @@ export default function QuickPane() {
async function continueFull() {
const handoffRequest =
request.trim() || submittedRequestRef.current?.request || "";
request.trim() || submittedRequestRef.current?.request || currentRequest;
if (invalid || !handoffRequest)
return setError(invalid ?? "Enter a request.");
try {
@@ -375,7 +464,7 @@ export default function QuickPane() {
agentId: result.runtime.agentId,
message: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
});
setStatus("Full session started with follow-up model.");
setNotice("Full session started with follow-up model.");
} catch (reason) {
setError(`Could not start full session: ${String(reason)}`);
}
@@ -383,62 +472,17 @@ export default function QuickPane() {
return (
<main className="quick-pane">
<header>
<strong>Quick Pane</strong>
<div className="quick-pane-header-controls">
{history.length > 0 && (
<label className="quick-pane-history">
<span>History</span>
<select
aria-label="Answer history"
value={selectedHistoryId}
onChange={(event) =>
setSelectedHistoryId(event.currentTarget.value)
}
>
<option value="">Current answer</option>
{history.map((entry) => (
<option key={entry.id} value={entry.id}>
{historyLabel(entry.request)}
</option>
))}
</select>
</label>
)}
<button onClick={dismissPane}>Close</button>
</div>
</header>
<textarea
className="quick-pane-request"
autoFocus
ref={inputRef}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void run();
}
}}
placeholder="Ask a quick question or give a quick instruction"
/>
<section className="quick-pane-output" aria-live="polite">
{status && <p>{status}</p>}
{displayedAnswer && <pre>{displayedAnswer}</pre>}
{error && <p role="alert">{error}</p>}
{catalogError && <p role="alert">{catalogError}</p>}
<ExtensionDialog extension={extension} onRespond={respond} />
</section>
<footer className="quick-pane-controls">
<label>
<span>Model</span>
<header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls">
<label className="quick-pane-field quick-pane-model">
<span className="full-label">Model</span>
<span className="compact-label" aria-hidden="true">
M
</span>
<select
aria-label="Model"
value={selectedModelValue}
disabled={runActive}
onChange={(event) => {
const identity = modelIdentityFromValue(
event.currentTarget.value,
@@ -463,11 +507,15 @@ export default function QuickPane() {
})}
</select>
</label>
<label>
<span>Thinking</span>
<label className="quick-pane-field quick-pane-thinking">
<span className="full-label">Thinking</span>
<span className="compact-label" aria-hidden="true">
T
</span>
<select
aria-label="Thinking"
value={thinkingLevel}
disabled={runActive}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
@@ -475,23 +523,167 @@ export default function QuickPane() {
<option value="high">High</option>
</select>
</label>
<button
disabled={
submissionStarting ||
!settingsLoaded ||
!!invalid ||
!request.trim()
</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)
}
onClick={run}
>
{submissionStarting ? "Starting…" : "Ask"}
<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"
aria-label="Close Quick Pane"
title="Close Quick Pane (Esc)"
onClick={dismissPane}
>
<span className="full-label">Close</span>
<span className="compact-label" aria-hidden="true">
×
</span>
</button>
{canContinue && (
<button className="quiet" onClick={continueFull}>
</div>
</header>
<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"
role="region"
aria-label="Assistant response"
aria-live={viewingHistory ? "off" : "polite"}
aria-busy={!viewingHistory && runActive}
>
{displayedAnswer && (
<>
<span className="message-author">Assistant</span>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayedAnswer}
</ReactMarkdown>
</>
)}
{displayedAnswerComplete && (
<span className="sr-only">Response complete.</span>
)}
</article>
)}
{!viewingHistory && activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
<i />
<i />
</span>
<span>{activity}</span>
</div>
)}
<span
className="quick-pane-activity-announcement sr-only"
aria-live="polite"
aria-atomic="true"
>
{viewingHistory ? "" : activityPhase}
</span>
{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>
</aside>
)}
{error && (
<aside className="quick-pane-notice error" role="alert">
<p>{error}</p>
{!!runtime && !escalation && (
<button type="button" onClick={continueFull}>
Continue
</button>
)}
</footer>
</aside>
)}
{catalogError && (
<aside className="quick-pane-notice error" role="alert">
<p>{catalogError}</p>
</aside>
)}
{notice && (
<p className="quick-pane-system-notice" role="status">
{notice}
</p>
)}
<ExtensionDialog extension={extension} onRespond={respond} />
</section>
<div className="quick-pane-composer">
<textarea
className="quick-pane-request"
aria-label="Request"
autoFocus
ref={inputRef}
readOnly={runActive}
aria-busy={runActive}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void run();
}
}}
placeholder="Ask a quick question or give a quick instruction"
/>
<button
className="quick-pane-send"
type="button"
aria-label={submissionStarting ? "Starting request" : "Send request"}
title="Send (Enter)"
disabled={
runActive || !settingsLoaded || !!invalid || !request.trim()
}
onClick={run}
>
<span aria-hidden="true">{submissionStarting ? "•••" : "↑"}</span>
</button>
<span className="quick-pane-composer-hint">
Shift+Enter for newline
</span>
</div>
</main>
);
}
+395 -110
View File
@@ -20,58 +20,82 @@ body {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
gap: 9px;
height: 100vh;
overflow: hidden;
padding: 10px;
color: #e6e3df;
font: 14px/1.35 Inter, ui-sans-serif, system-ui, sans-serif;
font:
14px / 1.4 Inter,
ui-sans-serif,
system-ui,
sans-serif;
background: rgba(34, 34, 34, 0.98);
border: 1px solid #4b4b4b;
border-radius: 8px;
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
}
.quick-pane header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #f0a347;
font-size: 11px;
font-weight: 900;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.quick-pane-header-controls,
.quick-pane-history {
.quick-pane-toolbar,
.quick-pane-primary-controls,
.quick-pane-header-actions,
.quick-pane-field {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
}
.quick-pane-header-controls {
.quick-pane-toolbar {
flex: 0 0 auto;
justify-content: space-between;
gap: 8px;
}
.quick-pane-primary-controls,
.quick-pane-header-actions {
gap: 6px;
}
.quick-pane-primary-controls {
flex: 1 1 auto;
overflow: hidden;
}
.quick-pane-header-actions {
flex: 0 1 auto;
justify-content: end;
overflow: hidden;
}
.quick-pane-field {
gap: 4px;
color: #bbb7b0;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quick-pane-model {
flex: 1 1 230px;
max-width: 310px;
}
.quick-pane-thinking {
flex: 0 1 122px;
}
.quick-pane-history {
color: #bbb7b0;
font-size: 9px;
flex: 0 1 205px;
}
.quick-pane-history select {
max-width: 240px;
padding-block: 3px;
font-size: 10px;
letter-spacing: 0;
text-transform: none;
.quick-pane .compact-label {
display: none;
}
.quick-pane button,
.quick-pane select {
box-sizing: border-box;
min-width: 0;
border: 0;
border-radius: 5px;
@@ -79,10 +103,19 @@ body {
font: inherit;
}
.quick-pane select {
width: 100%;
overflow: hidden;
color: #e6e3df;
background: #353535;
font-size: 11px;
text-overflow: ellipsis;
}
.quick-pane button {
flex: 0 0 auto;
background: #f0a347;
color: #25180b;
background: #f0a347;
font-weight: 750;
cursor: pointer;
}
@@ -91,113 +124,365 @@ body {
background: #ffc170;
}
.quick-pane button:disabled {
opacity: 0.6;
.quick-pane button:disabled,
.quick-pane select:disabled {
opacity: 0.58;
cursor: wait;
}
.quick-pane button.quiet {
color: #e6e3df;
background: #45413c;
.quick-pane button:focus-visible,
.quick-pane select:focus-visible,
.quick-pane-request:focus {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane select {
color: #e6e3df;
background: #353535;
.quick-pane-close {
color: #e6e3df !important;
background: #45413c !important;
font-size: 11px !important;
}
.quick-pane-exchange {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 9px;
overflow-x: hidden;
overflow-y: auto;
padding: 2px 3px 8px;
scroll-behavior: smooth;
}
.quick-pane-user-row {
display: flex;
flex: 0 0 auto;
justify-content: end;
}
.quick-pane-user-message,
.quick-pane-assistant-message {
box-sizing: border-box;
overflow-wrap: anywhere;
}
.quick-pane-user-message {
width: fit-content;
max-width: 78%;
padding: 7px 10px;
border: 1px solid #66513a;
border-radius: 12px 12px 3px 12px;
background: #3a3026;
}
.quick-pane-user-message p,
.quick-pane-notice p,
.quick-pane-system-notice {
margin: 0;
white-space: pre-wrap;
}
.message-author {
display: block;
margin-bottom: 3px;
color: #bdb7ae;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.quick-pane-assistant-message {
width: 100%;
flex: 0 0 auto;
padding: 10px 12px;
border: 1px solid #484848;
border-radius: 7px;
background: #292929;
}
.quick-pane-assistant-message > :last-child {
margin-bottom: 0;
}
.quick-pane-assistant-message p,
.quick-pane-assistant-message ul,
.quick-pane-assistant-message ol,
.quick-pane-assistant-message blockquote,
.quick-pane-assistant-message pre,
.quick-pane-assistant-message table {
margin-block: 0 9px;
}
.quick-pane-assistant-message ul,
.quick-pane-assistant-message ol {
padding-left: 22px;
}
.quick-pane-assistant-message a {
color: #ffc170;
}
.quick-pane-assistant-message code {
border-radius: 4px;
padding: 1px 4px;
color: #f3ded0;
background: #1d1d1d;
font:
0.9em / 1.45 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.quick-pane-assistant-message pre {
max-width: 100%;
overflow: auto;
border: 1px solid #414141;
border-radius: 6px;
padding: 9px;
background: #1d1d1d;
}
.quick-pane-assistant-message pre code {
padding: 0;
background: transparent;
}
.quick-pane-assistant-message blockquote {
border-left: 3px solid #80613e;
padding-left: 9px;
color: #c6c0b8;
}
.quick-pane-assistant-message table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.quick-pane-assistant-message th,
.quick-pane-assistant-message td {
border: 1px solid #4b4b4b;
padding: 4px 7px;
}
.quick-pane-activity {
display: flex;
min-height: 22px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
padding-inline: 3px;
color: #c5beb5;
font-size: 12px;
}
.typing-dots {
display: inline-flex;
align-items: center;
gap: 3px;
}
.typing-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: #f0a347;
animation: quick-pane-dot 1.15s infinite ease-in-out;
}
.typing-dots i:nth-child(2) {
animation-delay: 0.14s;
}
.typing-dots i:nth-child(3) {
animation-delay: 0.28s;
}
@keyframes quick-pane-dot {
0%,
60%,
100% {
opacity: 0.35;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.typing-dots i {
animation: none;
}
}
.quick-pane-notice {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid #65533e;
border-radius: 7px;
padding: 8px 10px;
color: #f1e4d1;
background: #3b3228;
}
.quick-pane-notice strong {
display: block;
margin-bottom: 2px;
}
.quick-pane-notice.error {
border-color: #70424a;
color: #ffd7db;
background: #3d252b;
}
.quick-pane-notice button {
align-self: center;
}
.quick-pane-system-notice {
flex: 0 0 auto;
padding: 6px 8px;
color: #c5beb5;
font-size: 12px;
}
.quick-pane-exchange .extension-backdrop,
.quick-pane-exchange .extension {
position: static;
display: block;
padding: 7px 9px;
background: transparent;
}
.quick-pane-exchange .extension-backdrop {
flex: 0 0 auto;
border: 1px solid #65533e;
border-radius: 7px;
background: #302b25;
}
.quick-pane-exchange .extension h2,
.quick-pane-exchange .extension p {
margin: 0 0 5px;
font-size: 12px;
}
.quick-pane-composer {
position: relative;
box-sizing: border-box;
height: 120px;
min-height: 110px;
max-height: 130px;
flex: 0 0 120px;
}
.quick-pane-request {
box-sizing: border-box;
width: 100%;
min-height: 180px;
flex: 1 1 auto;
padding: 12px;
height: 100%;
padding: 11px 50px 29px 11px;
resize: none;
border: 1px solid #484848;
border-radius: 6px;
border: 1px solid #55514c;
border-radius: 8px;
color: #e6e3df;
background: #292929;
font: inherit;
font-size: 16px;
font-size: 15px;
}
.quick-pane-request:focus,
.quick-pane button:focus-visible,
.quick-pane select:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
.quick-pane-request[readonly] {
color: #d1cbc4;
background: #272727;
}
.quick-pane-output {
max-height: 108px;
min-height: 0;
flex: 0 1 auto;
overflow: auto;
border: 1px solid #484848;
border-radius: 6px;
background: #292929;
.quick-pane-send {
position: absolute;
right: 8px;
bottom: 9px;
width: 34px;
height: 30px;
padding: 0 !important;
border-radius: 7px !important;
font-size: 20px !important;
line-height: 1 !important;
}
.quick-pane-output:empty {
display: none;
}
.quick-pane-output > pre,
.quick-pane-output > p {
margin: 0;
padding: 6px 8px;
white-space: pre-wrap;
}
.quick-pane-output > :not(:last-child) {
border-bottom: 1px solid #484848;
}
.quick-pane-output [role="alert"] {
color: #ffd7db;
background: #3d252b;
}
.quick-pane-controls {
display: flex;
flex: 0 0 auto;
align-items: end;
gap: 6px;
min-width: 0;
}
.quick-pane-controls label {
display: grid;
min-width: 0;
gap: 2px;
color: #bbb7b0;
.quick-pane-composer-hint {
position: absolute;
left: 11px;
bottom: 8px;
color: #96918b;
font-size: 10px;
line-height: 1;
pointer-events: none;
}
.quick-pane-controls label:first-child {
flex: 1 1 auto;
}
@media (max-width: 400px) {
.quick-pane {
padding: 8px;
}
.quick-pane-controls label:nth-child(2) {
flex: 0 1 100px;
}
.quick-pane .full-label {
display: none;
}
.quick-pane-controls select {
width: 100%;
font-size: 12px;
}
.quick-pane .compact-label {
display: inline;
flex: 0 0 auto;
}
.quick-pane-output .extension-backdrop,
.quick-pane-output .extension {
position: static;
display: block;
padding: 6px 8px;
background: transparent;
}
.quick-pane-toolbar,
.quick-pane-primary-controls,
.quick-pane-header-actions {
gap: 4px;
}
.quick-pane-output .extension h2,
.quick-pane-output .extension p {
margin: 0 0 4px;
font-size: 12px;
.quick-pane-model {
flex-basis: 130px;
}
.quick-pane-thinking {
flex-basis: 74px;
}
.quick-pane-history {
max-width: 78px;
}
.quick-pane-field {
gap: 2px;
}
.quick-pane select {
padding-inline: 5px;
font-size: 10px;
}
.quick-pane-close {
width: 27px;
height: 27px;
padding: 0 !important;
font-size: 18px !important;
}
.quick-pane-user-message {
max-width: 88%;
}
}