feat(quick-pane): add chat-style exchange
This commit is contained in:
+46
-15
@@ -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/);
|
||||
});
|
||||
|
||||
Generated
+1461
-7
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+389
-104
@@ -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 {
|
||||
.quick-pane-composer-hint {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
bottom: 8px;
|
||||
color: #96918b;
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.quick-pane {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.quick-pane .full-label {
|
||||
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;
|
||||
.quick-pane .compact-label {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
align-items: end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-pane-controls label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
.quick-pane-toolbar,
|
||||
.quick-pane-primary-controls,
|
||||
.quick-pane-header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-pane-model {
|
||||
flex-basis: 130px;
|
||||
}
|
||||
|
||||
.quick-pane-thinking {
|
||||
flex-basis: 74px;
|
||||
}
|
||||
|
||||
.quick-pane-history {
|
||||
max-width: 78px;
|
||||
}
|
||||
|
||||
.quick-pane-field {
|
||||
gap: 2px;
|
||||
color: #bbb7b0;
|
||||
}
|
||||
|
||||
.quick-pane select {
|
||||
padding-inline: 5px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quick-pane-controls label:first-child {
|
||||
flex: 1 1 auto;
|
||||
.quick-pane-close {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
padding: 0 !important;
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.quick-pane-controls label:nth-child(2) {
|
||||
flex: 0 1 100px;
|
||||
.quick-pane-user-message {
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.quick-pane-controls select {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-pane-output .extension-backdrop,
|
||||
.quick-pane-output .extension {
|
||||
position: static;
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quick-pane-output .extension h2,
|
||||
.quick-pane-output .extension p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user