fix(quick-pane): preserve lifecycle and answer history
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QuickPaneSettings, UiState } from "../workspace/types";
|
||||
@@ -47,7 +54,18 @@ const uiState: UiState = {
|
||||
quickPane: settings,
|
||||
};
|
||||
|
||||
describe("QuickPane model catalog errors", () => {
|
||||
function snapshot(answer: string, isStreaming: boolean) {
|
||||
return {
|
||||
runtime: {},
|
||||
state: { data: { isStreaming } },
|
||||
transcript: {
|
||||
data: { messages: [{ role: "assistant", content: answer }] },
|
||||
},
|
||||
extensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("QuickPane", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.focusHandler = undefined;
|
||||
@@ -115,4 +133,175 @@ describe("QuickPane model catalog errors", () => {
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allows only one runtime creation while a submission is starting", async () => {
|
||||
let resolveCreate:
|
||||
| ((value: { runtime: { agentId: string; runtimeId: string } }) => void)
|
||||
| undefined;
|
||||
const createRuntime = new Promise<{
|
||||
runtime: { agentId: string; runtimeId: string };
|
||||
}>((resolve) => {
|
||||
resolveCreate = 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 createRuntime;
|
||||
case "get_session_runtime_snapshot":
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
state: { data: { isStreaming: true } },
|
||||
extensions: [],
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
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" });
|
||||
fireEvent.click(ask);
|
||||
fireEvent.click(ask);
|
||||
|
||||
expect(
|
||||
mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "create_quick_runtime",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Starting…" })).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate?.({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
await createRuntime;
|
||||
});
|
||||
});
|
||||
|
||||
it("clears only the submitted request after a completed non-streaming answer", async () => {
|
||||
const user = userEvent.setup();
|
||||
let currentSnapshot = snapshot("Partial answer", 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.findByPlaceholderText(/Ask a quick question/);
|
||||
await user.type(input, "Inspect this");
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
|
||||
expect(await screen.findByText("Partial answer")).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Inspect this");
|
||||
|
||||
currentSnapshot = snapshot("Final answer", false);
|
||||
expect(
|
||||
await screen.findByText("Final answer", {}, { timeout: 2_000 }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
|
||||
await user.type(input, "Next draft");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
||||
expect(input).toHaveValue("Next draft");
|
||||
});
|
||||
|
||||
it("keeps five in-memory answers and history selection preserves current answer", async () => {
|
||||
const user = userEvent.setup();
|
||||
let runtimeCount = 0;
|
||||
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": {
|
||||
const segments = payload?.runtimeId?.split("-") ?? [];
|
||||
const id = Number(segments[segments.length - 1]);
|
||||
return Promise.resolve(snapshot(`Answer ${id}`, false));
|
||||
}
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(<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" }));
|
||||
expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
}
|
||||
|
||||
const history = screen.getByRole("combobox", { name: "Answer history" });
|
||||
expect(within(history).getAllByRole("option")).toHaveLength(6);
|
||||
expect(
|
||||
within(history).queryByRole("option", { name: "Request 1" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(history).getByRole("option", { name: "Request 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(history, "runtime-2");
|
||||
expect(screen.getByText("Answer 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Answer 6")).toBeNull();
|
||||
await user.selectOptions(history, "");
|
||||
expect(screen.getByText("Answer 6")).toBeInTheDocument();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({ models: [] });
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
expect(mocks.onCloseRequested).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mocks.hide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+111
-28
@@ -33,6 +33,12 @@ const defaults: QuickPaneSettings = {
|
||||
};
|
||||
|
||||
type Runtime = { agentId: string; runtimeId: string };
|
||||
type HistoryEntry = { id: string; request: string; answer: string };
|
||||
|
||||
function historyLabel(request: string) {
|
||||
const concise = request.replace(/\s+/g, " ").trim();
|
||||
return concise.length > 42 ? `${concise.slice(0, 39)}…` : concise;
|
||||
}
|
||||
|
||||
function settingsError(settings: QuickPaneSettings) {
|
||||
if (!settings.workspace.startsWith("/"))
|
||||
@@ -74,6 +80,7 @@ export default function QuickPane() {
|
||||
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [request, setRequest] = useState("");
|
||||
const [submissionStarting, setSubmissionStarting] = useState(false);
|
||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||||
@@ -85,9 +92,17 @@ export default function QuickPane() {
|
||||
const [catalogError, setCatalogError] = useState<string>();
|
||||
const [runtime, setRuntime] = useState<Runtime>();
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState("");
|
||||
const [escalation, setEscalation] = useState<string>();
|
||||
const [extension, setExtension] = useState<Extension>();
|
||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||
const submittedRequestRef = useRef<
|
||||
{ runtimeId: string; request: string } | undefined
|
||||
>(undefined);
|
||||
const completedRuntimeRef = useRef<string | undefined>(undefined);
|
||||
const submissionStartingRef = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const invalid = useMemo(() => settingsError(settings), [settings]);
|
||||
const modelOptions = useMemo(
|
||||
@@ -99,6 +114,10 @@ export default function QuickPane() {
|
||||
? modelIdentityValue(selectedModel)
|
||||
: "";
|
||||
const canContinue = !!escalation || (!!runtime && !!error);
|
||||
const displayedAnswer = selectedHistoryId
|
||||
? (history.find((entry) => entry.id === selectedHistoryId)?.answer ??
|
||||
answer)
|
||||
: answer;
|
||||
|
||||
const dismissPane = () => getCurrentWindow().hide();
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
@@ -170,15 +189,10 @@ export default function QuickPane() {
|
||||
void loadModelCatalog();
|
||||
}
|
||||
});
|
||||
const closeListener = paneWindow.onCloseRequested((event) => {
|
||||
event.preventDefault();
|
||||
void dismissPane();
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
void settingsListener.then((unlisten) => unlisten());
|
||||
void focusListener.then((unlisten) => unlisten());
|
||||
void closeListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [loadModelCatalog]);
|
||||
|
||||
@@ -202,8 +216,30 @@ export default function QuickPane() {
|
||||
text.replace("[ESCALATE]", "").trim() ||
|
||||
"Quick agent requested a full session.",
|
||||
);
|
||||
} else if (!snapshot.state?.data?.isStreaming) {
|
||||
}
|
||||
if (
|
||||
snapshot.state?.data?.isStreaming === false &&
|
||||
completedRuntimeRef.current !== runtime.runtimeId
|
||||
) {
|
||||
completedRuntimeRef.current = runtime.runtimeId;
|
||||
setCompletedRuntimeId(runtime.runtimeId);
|
||||
setStatus("Quick answer ready.");
|
||||
const submitted = submittedRequestRef.current;
|
||||
if (submitted?.runtimeId === runtime.runtimeId) {
|
||||
setRequest((current) =>
|
||||
current === submitted.request ? "" : current,
|
||||
);
|
||||
setHistory((current) =>
|
||||
[
|
||||
{
|
||||
id: runtime.runtimeId,
|
||||
request: submitted.request,
|
||||
answer: text,
|
||||
},
|
||||
...current.filter((entry) => entry.id !== runtime.runtimeId),
|
||||
].slice(0, 5),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
setExtension(snapshot.extensions[0]);
|
||||
@@ -227,7 +263,7 @@ export default function QuickPane() {
|
||||
}, [runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime) return;
|
||||
if (!runtime || completedRuntimeId === runtime.runtimeId) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setEscalation(
|
||||
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
|
||||
@@ -238,22 +274,32 @@ export default function QuickPane() {
|
||||
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
|
||||
}, settings.maximumSeconds * 1000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [runtime, settings.maximumSeconds]);
|
||||
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
|
||||
|
||||
async function run() {
|
||||
if (submissionStartingRef.current) return;
|
||||
if (invalid || !request.trim())
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
const previous = runtimeRef.current;
|
||||
if (previous)
|
||||
await invoke("close_quick_runtime", {
|
||||
runtimeId: previous.runtimeId,
|
||||
}).catch(() => undefined);
|
||||
setError(undefined);
|
||||
setAnswer("");
|
||||
setEscalation(undefined);
|
||||
setExtension(undefined);
|
||||
setStatus("Starting advisory quick agent…");
|
||||
submissionStartingRef.current = true;
|
||||
setSubmissionStarting(true);
|
||||
const submittedRequest = request;
|
||||
try {
|
||||
const previous = runtimeRef.current;
|
||||
if (previous)
|
||||
await invoke("close_quick_runtime", {
|
||||
runtimeId: previous.runtimeId,
|
||||
}).catch(() => undefined);
|
||||
runtimeRef.current = undefined;
|
||||
submittedRequestRef.current = undefined;
|
||||
completedRuntimeRef.current = undefined;
|
||||
setRuntime(undefined);
|
||||
setCompletedRuntimeId(undefined);
|
||||
setSelectedHistoryId("");
|
||||
setError(undefined);
|
||||
setAnswer("");
|
||||
setEscalation(undefined);
|
||||
setExtension(undefined);
|
||||
setStatus("Starting advisory quick agent…");
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_quick_runtime",
|
||||
{
|
||||
@@ -261,6 +307,10 @@ export default function QuickPane() {
|
||||
},
|
||||
);
|
||||
runtimeRef.current = result.runtime;
|
||||
submittedRequestRef.current = {
|
||||
runtimeId: result.runtime.runtimeId,
|
||||
request: submittedRequest,
|
||||
};
|
||||
setRuntime(result.runtime);
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
@@ -271,17 +321,22 @@ export default function QuickPane() {
|
||||
agentId: result.runtime.agentId,
|
||||
level: thinkingLevel,
|
||||
});
|
||||
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${request}`;
|
||||
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${submittedRequest}`;
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: guardrails,
|
||||
});
|
||||
setStatus(
|
||||
"Quick request running. Advisory limits are not trusted enforcement.",
|
||||
);
|
||||
if (completedRuntimeRef.current !== result.runtime.runtimeId) {
|
||||
setStatus(
|
||||
"Quick request running. Advisory limits are not trusted enforcement.",
|
||||
);
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(`Could not start quick request: ${String(reason)}`);
|
||||
setStatus("");
|
||||
} finally {
|
||||
submissionStartingRef.current = false;
|
||||
setSubmissionStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +355,9 @@ export default function QuickPane() {
|
||||
}
|
||||
|
||||
async function continueFull() {
|
||||
if (invalid || !request.trim())
|
||||
const handoffRequest =
|
||||
request.trim() || submittedRequestRef.current?.request || "";
|
||||
if (invalid || !handoffRequest)
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
@@ -316,7 +373,7 @@ export default function QuickPane() {
|
||||
});
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: `${request}\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.");
|
||||
} catch (reason) {
|
||||
@@ -328,7 +385,28 @@ export default function QuickPane() {
|
||||
<main className="quick-pane">
|
||||
<header>
|
||||
<strong>Quick Pane</strong>
|
||||
<button onClick={dismissPane}>Close</button>
|
||||
<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"
|
||||
@@ -350,7 +428,7 @@ export default function QuickPane() {
|
||||
/>
|
||||
<section className="quick-pane-output" aria-live="polite">
|
||||
{status && <p>{status}</p>}
|
||||
{answer && <pre>{answer}</pre>}
|
||||
{displayedAnswer && <pre>{displayedAnswer}</pre>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{catalogError && <p role="alert">{catalogError}</p>}
|
||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||
@@ -398,10 +476,15 @@ export default function QuickPane() {
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
||||
disabled={
|
||||
submissionStarting ||
|
||||
!settingsLoaded ||
|
||||
!!invalid ||
|
||||
!request.trim()
|
||||
}
|
||||
onClick={run}
|
||||
>
|
||||
Ask
|
||||
{submissionStarting ? "Starting…" : "Ask"}
|
||||
</button>
|
||||
{canContinue && (
|
||||
<button className="quiet" onClick={continueFull}>
|
||||
|
||||
@@ -37,6 +37,7 @@ body {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #f0a347;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
@@ -44,6 +45,31 @@ body {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane-header-controls,
|
||||
.quick-pane-history {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quick-pane-header-controls {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.quick-pane-history {
|
||||
color: #bbb7b0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.quick-pane-history select {
|
||||
max-width: 240px;
|
||||
padding-block: 3px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.quick-pane button,
|
||||
.quick-pane select {
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user