fix(quick-pane): preserve lifecycle and answer history

This commit is contained in:
2026-08-25 23:08:45 +02:00
parent 1b0b34fad8
commit 4fc9c60ade
8 changed files with 506 additions and 65 deletions
+111 -28
View File
@@ -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}>