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
+56 -25
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, /get_session_runtime_snapshot/);
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/); assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(source, /\{displayedAnswer && <pre>\{displayedAnswer\}<\/pre>\}/); assert.match(source, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
assert.match(source, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/); assert.match(source, /className="quick-pane-notice escalation"/);
assert.match(source, /\{canContinue && \(/); 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 () => { 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", "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, /getModelCatalog\(\)/);
assert.match(app, /Quick model[\s\S]*?<select/); assert.match(app, /Quick model[\s\S]*?<select/);
assert.match(app, /Follow-up 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.doesNotMatch(pane, /Save Quick Pane settings/);
assert.match(pane, /isStreaming === false/); assert.match(pane, /isStreaming === false/);
assert.match(pane, /current === submitted\.request \? "" : current/); 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, /\.slice\(0, 5\)/);
assert.match(pane, /aria-label="Answer history"/); assert.match(pane, /aria-label="Answer history"/);
assert.doesNotMatch(pane, /localStorage|sessionStorage|save_ui_state/); 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.match(pane, /modelId: selectedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/); assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/); 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\)/); assert.match(catalog, /JSON\.parse\(value\)/);
}); });
@@ -79,22 +90,31 @@ test("Quick Pane routes extension responses through existing bridge command", as
}); });
test("Quick Pane uses native dimensions and request-first compact layout", async () => { 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] =
readFile( await Promise.all([
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), readFile(
"utf8", new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
), "utf8",
readFile( ),
new URL("../ui/src/quick-pane/quick-pane.css", import.meta.url), readFile(
"utf8", 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/src/lib.rs", import.meta.url), "utf8"), readFile(
readFile(new URL("../ui/src-tauri/capabilities/default.json", import.meta.url), "utf8"), 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",
),
]);
const config = JSON.parse(configSource); const config = JSON.parse(configSource);
const capability = JSON.parse(capabilitySource); 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( 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.ok(capability.windows.includes("quick-pane"));
assert.match(nativeSource, /WindowEvent::CloseRequested/); assert.match(nativeSource, /WindowEvent::CloseRequested/);
assert.match(nativeSource, /api\.prevent_close\(\)/); 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.match(nativeSource, /get_or_create_quick_pane/);
assert.doesNotMatch(source, /onCloseRequested/); assert.doesNotMatch(source, /onCloseRequested/);
assert.match(source, /className="quick-pane-request"/); assert.match(source, /className="quick-pane-request"/);
assert.match(source, /className="quick-pane-output"/); assert.match(source, /className="quick-pane-exchange"/);
assert.match(source, /<footer className="quick-pane-controls">/); assert.match(source, /className="quick-pane-composer"/);
assert.match(source, /className="quick-pane-send"/);
assert.match(source, /ref=\{inputRef\}/); assert.match(source, /ref=\{inputRef\}/);
assert.match(source, /focusInput\(\)/); assert.match(source, /focusInput\(\)/);
assert.doesNotMatch(source, /if \(!event\.payload\)[\s\S]*?hide\(/); 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/, /event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
); );
assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/); assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/);
assert.match(stylesheet, /\.quick-pane-request[\s\S]*?flex: 1 1 auto/); assert.match(
assert.match(stylesheet, /\.quick-pane-output[\s\S]*?max-height: 108px[\s\S]*?overflow: auto/); stylesheet,
assert.match(stylesheet, /\.quick-pane-controls[\s\S]*?display: flex/); /\.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.match(stylesheet, /#f0a347/);
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/); 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-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0", "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": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
+413 -9
View File
@@ -112,8 +112,20 @@ describe("QuickPane", () => {
screen.getByPlaceholderText(/Ask a quick question/), screen.getByPlaceholderText(/Ask a quick question/),
"Inspect", "Inspect",
); );
await user.click(screen.getByRole("button", { name: "Ask" })); await user.click(screen.getByRole("button", { name: "Send request" }));
await screen.findByText(/Quick request running/); 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 })); await act(async () => mocks.focusHandler?.({ payload: true }));
expect( expect(
@@ -167,7 +179,7 @@ describe("QuickPane", () => {
render(<QuickPane />); render(<QuickPane />);
const input = await screen.findByPlaceholderText(/Ask a quick question/); const input = await screen.findByPlaceholderText(/Ask a quick question/);
fireEvent.change(input, { target: { value: "Inspect once" } }); 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);
fireEvent.click(ask); fireEvent.click(ask);
@@ -176,7 +188,11 @@ describe("QuickPane", () => {
([command]) => command === "create_quick_runtime", ([command]) => command === "create_quick_runtime",
), ),
).toHaveLength(1); ).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 () => { await act(async () => {
resolveCreate?.({ 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 () => { it("clears only the submitted request after a completed non-streaming answer", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let currentSnapshot = snapshot("Partial answer", true); let currentSnapshot = snapshot("Partial answer", true);
@@ -211,20 +286,44 @@ describe("QuickPane", () => {
render(<QuickPane />); render(<QuickPane />);
const input = await screen.findByPlaceholderText(/Ask a quick question/); const input = await screen.findByPlaceholderText(/Ask a quick question/);
await user.type(input, "Inspect this"); 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(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).toHaveValue("Inspect this");
expect(input).toHaveAttribute("readonly");
expect(input).toHaveFocus();
currentSnapshot = snapshot("Final answer", false); currentSnapshot = snapshot("Final answer", false);
expect( expect(
await screen.findByText("Final answer", {}, { timeout: 2_000 }), await screen.findByText("Final answer", {}, { timeout: 2_000 }),
).toBeInTheDocument(); ).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue("")); 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 user.type(input, "Next draft");
await new Promise((resolve) => window.setTimeout(resolve, 900)); await new Promise((resolve) => window.setTimeout(resolve, 900));
expect(input).toHaveValue("Next draft"); 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 () => { 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/); const input = await screen.findByPlaceholderText(/Ask a quick question/);
for (let index = 1; index <= 6; index += 1) { for (let index = 1; index <= 6; index += 1) {
await user.type(input, `Request ${index}`); 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(); expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
await waitFor(() => expect(input).toHaveValue("")); await waitFor(() => expect(input).toHaveValue(""));
} }
@@ -277,17 +376,320 @@ describe("QuickPane", () => {
within(history).getByRole("option", { name: "Request 2" }), within(history).getByRole("option", { name: "Request 2" }),
).toBeInTheDocument(); ).toBeInTheDocument();
await user.type(input, "Composer draft");
await user.selectOptions(history, "runtime-2"); 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(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, ""); 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( expect(mocks.invoke).not.toHaveBeenCalledWith(
"save_ui_state", "save_ui_state",
expect.anything(), 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 () => { it("leaves close interception native while preserving close and Escape hiding", async () => {
mocks.invoke.mockImplementation((command: string) => { mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(uiState); if (command === "load_ui_state") return Promise.resolve(uiState);
@@ -300,7 +702,9 @@ describe("QuickPane", () => {
await screen.findByPlaceholderText(/Ask a quick question/); await screen.findByPlaceholderText(/Ask a quick question/);
expect(mocks.onCloseRequested).not.toHaveBeenCalled(); 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" }); fireEvent.keyDown(window, { key: "Escape" });
expect(mocks.hide).toHaveBeenCalledTimes(2); expect(mocks.hide).toHaveBeenCalledTimes(2);
}); });
+327 -135
View File
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog"; import { ExtensionDialog } from "../components/ExtensionDialog";
import { import {
catalogModels, catalogModels,
@@ -20,6 +22,8 @@ import type {
UiState, UiState,
} from "../workspace/types"; } from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]";
const defaults: QuickPaneSettings = { const defaults: QuickPaneSettings = {
workspace: "", workspace: "",
provider: "", provider: "",
@@ -34,6 +38,10 @@ const defaults: QuickPaneSettings = {
type Runtime = { agentId: string; runtimeId: string }; type Runtime = { agentId: string; runtimeId: string };
type HistoryEntry = { id: string; request: string; answer: string }; type HistoryEntry = { id: string; request: string; answer: string };
type ActivitySnapshot = {
activeTool?: string;
hasAssistantText: boolean;
};
function historyLabel(request: string) { function historyLabel(request: string) {
const concise = request.replace(/\s+/g, " ").trim(); const concise = request.replace(/\s+/g, " ").trim();
@@ -76,10 +84,29 @@ function assistantText(snapshot: RuntimeSnapshot) {
.join("\n"); .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() { 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 [currentRequest, setCurrentRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false); const [submissionStarting, setSubmissionStarting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium"); const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]); const [models, setModels] = useState<Model[]>([]);
@@ -87,22 +114,29 @@ export default function QuickPane() {
provider: "", provider: "",
modelId: "", modelId: "",
}); });
const [status, setStatus] = useState("Loading Quick Pane settings…"); const [notice, setNotice] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [catalogError, setCatalogError] = useState<string>(); const [catalogError, setCatalogError] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>(); const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>(); const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
const [failedRuntimeId, setFailedRuntimeId] = useState<string>();
const [history, setHistory] = useState<HistoryEntry[]>([]); const [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState(""); const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [escalation, setEscalation] = useState<string>(); const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>(); const [extension, setExtension] = useState<Extension>();
const [activitySnapshot, setActivitySnapshot] = useState<ActivitySnapshot>({
hasAssistantText: false,
});
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const runtimeRef = useRef<Runtime | undefined>(undefined); const runtimeRef = useRef<Runtime | undefined>(undefined);
const submittedRequestRef = useRef< const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined { runtimeId: string; request: string } | undefined
>(undefined); >(undefined);
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
const completedRuntimeRef = useRef<string | undefined>(undefined); const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false); const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]); const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo( const modelOptions = useMemo(
@@ -113,11 +147,32 @@ export default function QuickPane() {
selectedModel.provider && selectedModel.modelId selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel) ? modelIdentityValue(selectedModel)
: ""; : "";
const canContinue = !!escalation || (!!runtime && !!error); const runActive =
const displayedAnswer = selectedHistoryId submissionStarting ||
? (history.find((entry) => entry.id === selectedHistoryId)?.answer ?? (!!runtime &&
answer) completedRuntimeId !== runtime.runtimeId &&
: answer; 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 dismissPane = () => getCurrentWindow().hide();
const focusInput = () => inputRef.current?.focus(); const focusInput = () => inputRef.current?.focus();
@@ -149,7 +204,7 @@ export default function QuickPane() {
provider: nextSettings.provider, provider: nextSettings.provider,
modelId: nextSettings.model, modelId: nextSettings.model,
}); });
setStatus(""); setNotice("");
if (active) await loadModelCatalog(); if (active) await loadModelCatalog();
})(); })();
return () => { return () => {
@@ -166,6 +221,22 @@ export default function QuickPane() {
if (settingsLoaded) focusInput(); if (settingsLoaded) focusInput();
}, [settingsLoaded]); }, [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(() => { useEffect(() => {
const paneWindow = getCurrentWindow(); const paneWindow = getCurrentWindow();
const settingsListener = listen<QuickPaneSettings>( const settingsListener = listen<QuickPaneSettings>(
@@ -209,42 +280,58 @@ export default function QuickPane() {
); );
if (!active) return; if (!active) return;
const text = assistantText(snapshot); const text = assistantText(snapshot);
if (text) { const responseComplete =
setAnswer(text); snapshot.state?.data?.isStreaming === false &&
if (text.includes("[ESCALATE]")) { promptSubmittedRuntimeRef.current === runtime.runtimeId;
setEscalation( const escalated = text.startsWith(ESCALATION_MARKER);
text.replace("[ESCALATE]", "").trim() || const awaitingEscalationMarker =
"Quick agent requested a full session.", !!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 (
responseComplete &&
completedRuntimeRef.current !== runtime.runtimeId
) {
completedRuntimeRef.current = runtime.runtimeId;
setCompletedRuntimeId(runtime.runtimeId);
setNotice("");
const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) {
setRequest((current) =>
current === submitted.request ? "" : current,
);
setHistory((current) =>
[
{
id: runtime.runtimeId,
request: submitted.request,
answer: result,
},
...current.filter((entry) => entry.id !== runtime.runtimeId),
].slice(0, 5),
); );
} }
if ( window.requestAnimationFrame(focusInput);
snapshot.state?.data?.isStreaming === false &&
completedRuntimeRef.current !== runtime.runtimeId
) {
completedRuntimeRef.current = runtime.runtimeId;
setCompletedRuntimeId(runtime.runtimeId);
setStatus("Quick answer ready.");
const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) {
setRequest((current) =>
current === submitted.request ? "" : current,
);
setHistory((current) =>
[
{
id: runtime.runtimeId,
request: submitted.request,
answer: text,
},
...current.filter((entry) => entry.id !== runtime.runtimeId),
].slice(0, 5),
);
}
}
} }
setExtension(snapshot.extensions[0]); setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) { if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message); setError(snapshot.runtime.error.message);
setFailedRuntimeId(runtime.runtimeId);
setEscalation( setEscalation(
"Quick runtime failed. Continue in a full session if needed.", "Quick runtime failed. Continue in a full session if needed.",
); );
@@ -268,21 +355,23 @@ export default function QuickPane() {
setEscalation( setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`, `Quick time budget of ${settings.maximumSeconds} seconds reached.`,
); );
setStatus( setFailedRuntimeId(runtime.runtimeId);
"Quick time budget reached. Showing partial result if available.",
);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined); invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000); }, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [completedRuntimeId, runtime, settings.maximumSeconds]); }, [completedRuntimeId, runtime, settings.maximumSeconds]);
async function run() { async function run() {
if (submissionStartingRef.current) return; if (submissionStartingRef.current || runActive) return;
if (invalid || !request.trim()) if (invalid || !request.trim())
return setError(invalid ?? "Enter a request."); return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true; submissionStartingRef.current = true;
setSubmissionStarting(true); setSubmissionStarting(true);
const submittedRequest = request; const submittedRequest = request;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
runStartedAtRef.current = Date.now();
setElapsedSeconds(0);
try { try {
const previous = runtimeRef.current; const previous = runtimeRef.current;
if (previous) if (previous)
@@ -291,15 +380,17 @@ export default function QuickPane() {
}).catch(() => undefined); }).catch(() => undefined);
runtimeRef.current = undefined; runtimeRef.current = undefined;
submittedRequestRef.current = undefined; submittedRequestRef.current = undefined;
promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined; completedRuntimeRef.current = undefined;
setRuntime(undefined); setRuntime(undefined);
setCompletedRuntimeId(undefined); setCompletedRuntimeId(undefined);
setSelectedHistoryId(""); setFailedRuntimeId(undefined);
setError(undefined); setError(undefined);
setAnswer(""); setAnswer("");
setEscalation(undefined); setEscalation(undefined);
setExtension(undefined); setExtension(undefined);
setStatus("Starting advisory quick agent…"); setActivitySnapshot({ hasAssistantText: false });
setNotice("");
const result = await invoke<{ runtime: Runtime }>( const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime", "create_quick_runtime",
{ {
@@ -326,17 +417,15 @@ export default function QuickPane() {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
message: guardrails, message: guardrails,
}); });
if (completedRuntimeRef.current !== result.runtime.runtimeId) { promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
setStatus(
"Quick request running. Advisory limits are not trusted enforcement.",
);
}
} catch (reason) { } catch (reason) {
setError(`Could not start quick request: ${String(reason)}`); setError(`Could not start quick request: ${String(reason)}`);
setStatus(""); setFailedRuntimeId(runtimeRef.current?.runtimeId);
} finally { } finally {
submissionStartingRef.current = false; submissionStartingRef.current = false;
setSubmissionStarting(false); setSubmissionStarting(false);
focusInput();
window.requestAnimationFrame(focusInput);
} }
} }
@@ -356,7 +445,7 @@ export default function QuickPane() {
async function continueFull() { async function continueFull() {
const handoffRequest = const handoffRequest =
request.trim() || submittedRequestRef.current?.request || ""; request.trim() || submittedRequestRef.current?.request || currentRequest;
if (invalid || !handoffRequest) if (invalid || !handoffRequest)
return setError(invalid ?? "Enter a request."); return setError(invalid ?? "Enter a request.");
try { try {
@@ -375,7 +464,7 @@ export default function QuickPane() {
agentId: result.runtime.agentId, agentId: result.runtime.agentId,
message: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`, message: `${handoffRequest}\n\nQuick Pane 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) { } catch (reason) {
setError(`Could not start full session: ${String(reason)}`); setError(`Could not start full session: ${String(reason)}`);
} }
@@ -383,20 +472,74 @@ export default function QuickPane() {
return ( return (
<main className="quick-pane"> <main className="quick-pane">
<header> <header className="quick-pane-toolbar">
<strong>Quick Pane</strong> <div className="quick-pane-primary-controls">
<div className="quick-pane-header-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,
);
if (identity) setSelectedModel(identity);
}}
>
<option value="" disabled>
Select model
</option>
{modelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
</label>
<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>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
</div>
<div className="quick-pane-header-actions">
{history.length > 0 && ( {history.length > 0 && (
<label className="quick-pane-history"> <label className="quick-pane-field quick-pane-history">
<span>History</span> <span className="full-label">History</span>
<span className="compact-label" aria-hidden="true">
H
</span>
<select <select
aria-label="Answer history" aria-label="Answer history"
value={selectedHistoryId} value={viewingHistory ? selectedHistoryId : ""}
disabled={runActive}
onChange={(event) => onChange={(event) =>
setSelectedHistoryId(event.currentTarget.value) setSelectedHistoryId(event.currentTarget.value)
} }
> >
<option value="">Current answer</option> <option value="">Current</option>
{history.map((entry) => ( {history.map((entry) => (
<option key={entry.id} value={entry.id}> <option key={entry.id} value={entry.id}>
{historyLabel(entry.request)} {historyLabel(entry.request)}
@@ -405,93 +548,142 @@ export default function QuickPane() {
</select> </select>
</label> </label>
)} )}
<button onClick={dismissPane}>Close</button> <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>
</div> </div>
</header> </header>
<textarea
className="quick-pane-request" <section className="quick-pane-exchange" aria-label="Quick exchange">
autoFocus {displayedRequest && (
ref={inputRef} <div className="quick-pane-user-row">
value={request} <article className="quick-pane-user-message">
onChange={(event) => setRequest(event.currentTarget.value)} <span className="message-author">You</span>
onKeyDown={(event) => { <p>{displayedRequest}</p>
if ( </article>
event.key === "Enter" && </div>
!event.shiftKey && )}
!event.nativeEvent.isComposing {(viewingHistory
) { ? !!displayedAnswer
event.preventDefault(); : runActive || !!displayedAnswer || displayedAnswerComplete) && (
void run(); <article
} className="quick-pane-assistant-message"
}} role="region"
placeholder="Ask a quick question or give a quick instruction" aria-label="Assistant response"
/> aria-live={viewingHistory ? "off" : "polite"}
<section className="quick-pane-output" aria-live="polite"> aria-busy={!viewingHistory && runActive}
{status && <p>{status}</p>} >
{displayedAnswer && <pre>{displayedAnswer}</pre>} {displayedAnswer && (
{error && <p role="alert">{error}</p>} <>
{catalogError && <p role="alert">{catalogError}</p>} <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>
)}
</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} /> <ExtensionDialog extension={extension} onRespond={respond} />
</section> </section>
<footer className="quick-pane-controls">
<label> <div className="quick-pane-composer">
<span>Model</span> <textarea
<select className="quick-pane-request"
aria-label="Model" aria-label="Request"
value={selectedModelValue} autoFocus
onChange={(event) => { ref={inputRef}
const identity = modelIdentityFromValue( readOnly={runActive}
event.currentTarget.value, aria-busy={runActive}
); value={request}
if (identity) setSelectedModel(identity); onChange={(event) => setRequest(event.currentTarget.value)}
}} onKeyDown={(event) => {
> if (
<option value="" disabled> event.key === "Enter" &&
Select model !event.shiftKey &&
</option> !event.nativeEvent.isComposing
{modelOptions.map((model) => { ) {
const identity = modelIdentity(model); event.preventDefault();
if (!identity) return null; void run();
return ( }
<option }}
key={modelIdentityValue(identity)} placeholder="Ask a quick question or give a quick instruction"
value={modelIdentityValue(identity)} />
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
</label>
<label>
<span>Thinking</span>
<select
aria-label="Thinking"
value={thinkingLevel}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button <button
className="quick-pane-send"
type="button"
aria-label={submissionStarting ? "Starting request" : "Send request"}
title="Send (Enter)"
disabled={ disabled={
submissionStarting || runActive || !settingsLoaded || !!invalid || !request.trim()
!settingsLoaded ||
!!invalid ||
!request.trim()
} }
onClick={run} onClick={run}
> >
{submissionStarting ? "Starting…" : "Ask"} <span aria-hidden="true">{submissionStarting ? "•••" : "↑"}</span>
</button> </button>
{canContinue && ( <span className="quick-pane-composer-hint">
<button className="quiet" onClick={continueFull}> Shift+Enter for newline
Continue </span>
</button> </div>
)}
</footer>
</main> </main>
); );
} }
+395 -110
View File
@@ -20,58 +20,82 @@ body {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 9px;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
padding: 10px; padding: 10px;
color: #e6e3df; 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); background: rgba(34, 34, 34, 0.98);
border: 1px solid #4b4b4b; border: 1px solid #4b4b4b;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55); box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
} }
.quick-pane header { .quick-pane-toolbar,
display: flex; .quick-pane-primary-controls,
flex: 0 0 auto; .quick-pane-header-actions,
align-items: center; .quick-pane-field {
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 {
display: flex; display: flex;
min-width: 0; min-width: 0;
align-items: center; 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; 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 { .quick-pane-history {
color: #bbb7b0; flex: 0 1 205px;
font-size: 9px;
} }
.quick-pane-history select { .quick-pane .compact-label {
max-width: 240px; display: none;
padding-block: 3px;
font-size: 10px;
letter-spacing: 0;
text-transform: none;
} }
.quick-pane button, .quick-pane button,
.quick-pane select { .quick-pane select {
box-sizing: border-box;
min-width: 0; min-width: 0;
border: 0; border: 0;
border-radius: 5px; border-radius: 5px;
@@ -79,10 +103,19 @@ body {
font: inherit; font: inherit;
} }
.quick-pane select {
width: 100%;
overflow: hidden;
color: #e6e3df;
background: #353535;
font-size: 11px;
text-overflow: ellipsis;
}
.quick-pane button { .quick-pane button {
flex: 0 0 auto; flex: 0 0 auto;
background: #f0a347;
color: #25180b; color: #25180b;
background: #f0a347;
font-weight: 750; font-weight: 750;
cursor: pointer; cursor: pointer;
} }
@@ -91,113 +124,365 @@ body {
background: #ffc170; background: #ffc170;
} }
.quick-pane button:disabled { .quick-pane button:disabled,
opacity: 0.6; .quick-pane select:disabled {
opacity: 0.58;
cursor: wait; cursor: wait;
} }
.quick-pane button.quiet { .quick-pane button:focus-visible,
color: #e6e3df; .quick-pane select:focus-visible,
background: #45413c; .quick-pane-request:focus {
outline: 3px solid #f0a347;
outline-offset: 2px;
} }
.quick-pane select { .quick-pane-close {
color: #e6e3df; color: #e6e3df !important;
background: #353535; 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 { .quick-pane-request {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: 180px; height: 100%;
flex: 1 1 auto; padding: 11px 50px 29px 11px;
padding: 12px;
resize: none; resize: none;
border: 1px solid #484848; border: 1px solid #55514c;
border-radius: 6px; border-radius: 8px;
color: #e6e3df; color: #e6e3df;
background: #292929; background: #292929;
font: inherit; font: inherit;
font-size: 16px; font-size: 15px;
} }
.quick-pane-request:focus, .quick-pane-request[readonly] {
.quick-pane button:focus-visible, color: #d1cbc4;
.quick-pane select:focus-visible { background: #272727;
outline: 3px solid #f0a347;
outline-offset: 2px;
} }
.quick-pane-output { .quick-pane-send {
max-height: 108px; position: absolute;
min-height: 0; right: 8px;
flex: 0 1 auto; bottom: 9px;
overflow: auto; width: 34px;
border: 1px solid #484848; height: 30px;
border-radius: 6px; padding: 0 !important;
background: #292929; border-radius: 7px !important;
font-size: 20px !important;
line-height: 1 !important;
} }
.quick-pane-output:empty { .quick-pane-composer-hint {
display: none; position: absolute;
} left: 11px;
bottom: 8px;
.quick-pane-output > pre, color: #96918b;
.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;
font-size: 10px; font-size: 10px;
line-height: 1; pointer-events: none;
} }
.quick-pane-controls label:first-child { @media (max-width: 400px) {
flex: 1 1 auto; .quick-pane {
} padding: 8px;
}
.quick-pane-controls label:nth-child(2) { .quick-pane .full-label {
flex: 0 1 100px; display: none;
} }
.quick-pane-controls select { .quick-pane .compact-label {
width: 100%; display: inline;
font-size: 12px; flex: 0 0 auto;
} }
.quick-pane-output .extension-backdrop, .quick-pane-toolbar,
.quick-pane-output .extension { .quick-pane-primary-controls,
position: static; .quick-pane-header-actions {
display: block; gap: 4px;
padding: 6px 8px; }
background: transparent;
}
.quick-pane-output .extension h2, .quick-pane-model {
.quick-pane-output .extension p { flex-basis: 130px;
margin: 0 0 4px; }
font-size: 12px;
.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%;
}
} }