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, /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,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 () => {
|
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(
|
readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
"utf8",
|
"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),
|
new URL("../ui/src/quick-pane/quick-pane.css", import.meta.url),
|
||||||
"utf8",
|
"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/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 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/);
|
||||||
});
|
});
|
||||||
|
|||||||
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-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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
+290
-98
@@ -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,21 +280,36 @@ 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 (
|
if (
|
||||||
snapshot.state?.data?.isStreaming === false &&
|
responseComplete &&
|
||||||
completedRuntimeRef.current !== runtime.runtimeId
|
completedRuntimeRef.current !== runtime.runtimeId
|
||||||
) {
|
) {
|
||||||
completedRuntimeRef.current = runtime.runtimeId;
|
completedRuntimeRef.current = runtime.runtimeId;
|
||||||
setCompletedRuntimeId(runtime.runtimeId);
|
setCompletedRuntimeId(runtime.runtimeId);
|
||||||
setStatus("Quick answer ready.");
|
setNotice("");
|
||||||
const submitted = submittedRequestRef.current;
|
const submitted = submittedRequestRef.current;
|
||||||
if (submitted?.runtimeId === runtime.runtimeId) {
|
if (submitted?.runtimeId === runtime.runtimeId) {
|
||||||
setRequest((current) =>
|
setRequest((current) =>
|
||||||
@@ -234,17 +320,18 @@ export default function QuickPane() {
|
|||||||
{
|
{
|
||||||
id: runtime.runtimeId,
|
id: runtime.runtimeId,
|
||||||
request: submitted.request,
|
request: submitted.request,
|
||||||
answer: text,
|
answer: result,
|
||||||
},
|
},
|
||||||
...current.filter((entry) => entry.id !== runtime.runtimeId),
|
...current.filter((entry) => entry.id !== runtime.runtimeId),
|
||||||
].slice(0, 5),
|
].slice(0, 5),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
window.requestAnimationFrame(focusInput);
|
||||||
}
|
}
|
||||||
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,62 +472,17 @@ 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">
|
||||||
{history.length > 0 && (
|
<span className="full-label">Model</span>
|
||||||
<label className="quick-pane-history">
|
<span className="compact-label" aria-hidden="true">
|
||||||
<span>History</span>
|
M
|
||||||
<select
|
</span>
|
||||||
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>
|
|
||||||
<select
|
<select
|
||||||
aria-label="Model"
|
aria-label="Model"
|
||||||
value={selectedModelValue}
|
value={selectedModelValue}
|
||||||
|
disabled={runActive}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const identity = modelIdentityFromValue(
|
const identity = modelIdentityFromValue(
|
||||||
event.currentTarget.value,
|
event.currentTarget.value,
|
||||||
@@ -463,11 +507,15 @@ export default function QuickPane() {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="quick-pane-field quick-pane-thinking">
|
||||||
<span>Thinking</span>
|
<span className="full-label">Thinking</span>
|
||||||
|
<span className="compact-label" aria-hidden="true">
|
||||||
|
T
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
aria-label="Thinking"
|
aria-label="Thinking"
|
||||||
value={thinkingLevel}
|
value={thinkingLevel}
|
||||||
|
disabled={runActive}
|
||||||
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
||||||
>
|
>
|
||||||
<option value="low">Low</option>
|
<option value="low">Low</option>
|
||||||
@@ -475,23 +523,167 @@ export default function QuickPane() {
|
|||||||
<option value="high">High</option>
|
<option value="high">High</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button
|
</div>
|
||||||
disabled={
|
<div className="quick-pane-header-actions">
|
||||||
submissionStarting ||
|
{history.length > 0 && (
|
||||||
!settingsLoaded ||
|
<label className="quick-pane-field quick-pane-history">
|
||||||
!!invalid ||
|
<span className="full-label">History</span>
|
||||||
!request.trim()
|
<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>
|
</button>
|
||||||
{canContinue && (
|
</div>
|
||||||
<button className="quiet" onClick={continueFull}>
|
</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
|
Continue
|
||||||
</button>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+389
-104
@@ -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 {
|
||||||
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-output > pre,
|
.quick-pane .compact-label {
|
||||||
.quick-pane-output > p {
|
display: inline;
|
||||||
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;
|
flex: 0 0 auto;
|
||||||
align-items: end;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-controls label {
|
.quick-pane-toolbar,
|
||||||
display: grid;
|
.quick-pane-primary-controls,
|
||||||
min-width: 0;
|
.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;
|
gap: 2px;
|
||||||
color: #bbb7b0;
|
}
|
||||||
|
|
||||||
|
.quick-pane select {
|
||||||
|
padding-inline: 5px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-controls label:first-child {
|
.quick-pane-close {
|
||||||
flex: 1 1 auto;
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 18px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-controls label:nth-child(2) {
|
.quick-pane-user-message {
|
||||||
flex: 0 1 100px;
|
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