feat(quick-pane): add chat-style exchange

This commit is contained in:
2026-08-26 15:26:03 +02:00
parent 0ec7d224a2
commit d34099771c
6 changed files with 2655 additions and 287 deletions
+327 -135
View File
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog";
import {
catalogModels,
@@ -20,6 +22,8 @@ import type {
UiState,
} from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]";
const defaults: QuickPaneSettings = {
workspace: "",
provider: "",
@@ -34,6 +38,10 @@ const defaults: QuickPaneSettings = {
type Runtime = { agentId: string; runtimeId: string };
type HistoryEntry = { id: string; request: string; answer: string };
type ActivitySnapshot = {
activeTool?: string;
hasAssistantText: boolean;
};
function historyLabel(request: string) {
const concise = request.replace(/\s+/g, " ").trim();
@@ -76,10 +84,29 @@ function assistantText(snapshot: RuntimeSnapshot) {
.join("\n");
}
function toolActivityLabel(tool: string) {
const segments = tool.split(".");
const name = segments[segments.length - 1]?.replace(/[_-]+/g, " ") ?? tool;
const labels: Record<string, string> = {
bash: "Running command",
edit: "Editing file",
find: "Finding files",
grep: "Searching files",
ls: "Listing files",
read: "Reading file",
write: "Writing file",
};
return (
labels[name.toLowerCase()] ??
`${name.charAt(0).toUpperCase()}${name.slice(1)}`
);
}
export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [request, setRequest] = useState("");
const [currentRequest, setCurrentRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
@@ -87,22 +114,29 @@ export default function QuickPane() {
provider: "",
modelId: "",
});
const [status, setStatus] = useState("Loading Quick Pane settings…");
const [notice, setNotice] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>();
const [catalogError, setCatalogError] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
const [failedRuntimeId, setFailedRuntimeId] = useState<string>();
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
const [activitySnapshot, setActivitySnapshot] = useState<ActivitySnapshot>({
hasAssistantText: false,
});
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const runtimeRef = useRef<Runtime | undefined>(undefined);
const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined
>(undefined);
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo(
@@ -113,11 +147,32 @@ export default function QuickPane() {
selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel)
: "";
const canContinue = !!escalation || (!!runtime && !!error);
const displayedAnswer = selectedHistoryId
? (history.find((entry) => entry.id === selectedHistoryId)?.answer ??
answer)
: answer;
const runActive =
submissionStarting ||
(!!runtime &&
completedRuntimeId !== runtime.runtimeId &&
failedRuntimeId !== runtime.runtimeId);
const viewingHistory = !!selectedHistoryId && !runActive;
const displayedEntry = viewingHistory
? history.find((entry) => entry.id === selectedHistoryId)
: undefined;
const displayedRequest = displayedEntry?.request ?? currentRequest;
const displayedAnswer = displayedEntry?.answer ?? answer;
const displayedAnswerComplete =
!viewingHistory && !!runtime && completedRuntimeId === runtime.runtimeId;
const activityPhase = submissionStarting
? "Starting"
: runActive && activitySnapshot.activeTool
? toolActivityLabel(activitySnapshot.activeTool)
: runActive && activitySnapshot.hasAssistantText
? "Answering"
: runActive
? "Thinking"
: "";
const activity =
activityPhase === "Thinking"
? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase;
const dismissPane = () => getCurrentWindow().hide();
const focusInput = () => inputRef.current?.focus();
@@ -149,7 +204,7 @@ export default function QuickPane() {
provider: nextSettings.provider,
modelId: nextSettings.model,
});
setStatus("");
setNotice("");
if (active) await loadModelCatalog();
})();
return () => {
@@ -166,6 +221,22 @@ export default function QuickPane() {
if (settingsLoaded) focusInput();
}, [settingsLoaded]);
useEffect(() => {
if (!runActive) return;
const updateElapsed = () =>
setElapsedSeconds(
Math.max(
0,
Math.floor(
(Date.now() - (runStartedAtRef.current ?? Date.now())) / 1000,
),
),
);
updateElapsed();
const interval = window.setInterval(updateElapsed, 1_000);
return () => window.clearInterval(interval);
}, [runActive]);
useEffect(() => {
const paneWindow = getCurrentWindow();
const settingsListener = listen<QuickPaneSettings>(
@@ -209,42 +280,58 @@ export default function QuickPane() {
);
if (!active) return;
const text = assistantText(snapshot);
if (text) {
setAnswer(text);
if (text.includes("[ESCALATE]")) {
setEscalation(
text.replace("[ESCALATE]", "").trim() ||
"Quick agent requested a full session.",
const responseComplete =
snapshot.state?.data?.isStreaming === false &&
promptSubmittedRuntimeRef.current === runtime.runtimeId;
const escalated = text.startsWith(ESCALATION_MARKER);
const awaitingEscalationMarker =
!!text && ESCALATION_MARKER.startsWith(text);
const result = awaitingEscalationMarker
? ""
: escalated
? text.slice(ESCALATION_MARKER.length).trim()
: text;
setActivitySnapshot({
activeTool: snapshot.runtime.activeTool,
hasAssistantText: !!text,
});
if (awaitingEscalationMarker) {
setAnswer("");
} else if (text) {
setAnswer(result);
}
if (escalated && responseComplete) {
setEscalation(result || "Quick agent requested a full session.");
}
if (
responseComplete &&
completedRuntimeRef.current !== runtime.runtimeId
) {
completedRuntimeRef.current = runtime.runtimeId;
setCompletedRuntimeId(runtime.runtimeId);
setNotice("");
const submitted = submittedRequestRef.current;
if (submitted?.runtimeId === runtime.runtimeId) {
setRequest((current) =>
current === submitted.request ? "" : current,
);
setHistory((current) =>
[
{
id: runtime.runtimeId,
request: submitted.request,
answer: result,
},
...current.filter((entry) => entry.id !== runtime.runtimeId),
].slice(0, 5),
);
}
if (
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),
);
}
}
window.requestAnimationFrame(focusInput);
}
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
setFailedRuntimeId(runtime.runtimeId);
setEscalation(
"Quick runtime failed. Continue in a full session if needed.",
);
@@ -268,21 +355,23 @@ export default function QuickPane() {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
setStatus(
"Quick time budget reached. Showing partial result if available.",
);
setFailedRuntimeId(runtime.runtimeId);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
async function run() {
if (submissionStartingRef.current) return;
if (submissionStartingRef.current || runActive) return;
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
submissionStartingRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
runStartedAtRef.current = Date.now();
setElapsedSeconds(0);
try {
const previous = runtimeRef.current;
if (previous)
@@ -291,15 +380,17 @@ export default function QuickPane() {
}).catch(() => undefined);
runtimeRef.current = undefined;
submittedRequestRef.current = undefined;
promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined;
setRuntime(undefined);
setCompletedRuntimeId(undefined);
setSelectedHistoryId("");
setFailedRuntimeId(undefined);
setError(undefined);
setAnswer("");
setEscalation(undefined);
setExtension(undefined);
setStatus("Starting advisory quick agent…");
setActivitySnapshot({ hasAssistantText: false });
setNotice("");
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
{
@@ -326,17 +417,15 @@ export default function QuickPane() {
agentId: result.runtime.agentId,
message: guardrails,
});
if (completedRuntimeRef.current !== result.runtime.runtimeId) {
setStatus(
"Quick request running. Advisory limits are not trusted enforcement.",
);
}
promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
} catch (reason) {
setError(`Could not start quick request: ${String(reason)}`);
setStatus("");
setFailedRuntimeId(runtimeRef.current?.runtimeId);
} finally {
submissionStartingRef.current = false;
setSubmissionStarting(false);
focusInput();
window.requestAnimationFrame(focusInput);
}
}
@@ -356,7 +445,7 @@ export default function QuickPane() {
async function continueFull() {
const handoffRequest =
request.trim() || submittedRequestRef.current?.request || "";
request.trim() || submittedRequestRef.current?.request || currentRequest;
if (invalid || !handoffRequest)
return setError(invalid ?? "Enter a request.");
try {
@@ -375,7 +464,7 @@ export default function QuickPane() {
agentId: result.runtime.agentId,
message: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
});
setStatus("Full session started with follow-up model.");
setNotice("Full session started with follow-up model.");
} catch (reason) {
setError(`Could not start full session: ${String(reason)}`);
}
@@ -383,20 +472,74 @@ export default function QuickPane() {
return (
<main className="quick-pane">
<header>
<strong>Quick Pane</strong>
<div className="quick-pane-header-controls">
<header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls">
<label className="quick-pane-field quick-pane-model">
<span className="full-label">Model</span>
<span className="compact-label" aria-hidden="true">
M
</span>
<select
aria-label="Model"
value={selectedModelValue}
disabled={runActive}
onChange={(event) => {
const identity = modelIdentityFromValue(
event.currentTarget.value,
);
if (identity) setSelectedModel(identity);
}}
>
<option value="" disabled>
Select model
</option>
{modelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
</label>
<label className="quick-pane-field quick-pane-thinking">
<span className="full-label">Thinking</span>
<span className="compact-label" aria-hidden="true">
T
</span>
<select
aria-label="Thinking"
value={thinkingLevel}
disabled={runActive}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
</div>
<div className="quick-pane-header-actions">
{history.length > 0 && (
<label className="quick-pane-history">
<span>History</span>
<label className="quick-pane-field quick-pane-history">
<span className="full-label">History</span>
<span className="compact-label" aria-hidden="true">
H
</span>
<select
aria-label="Answer history"
value={selectedHistoryId}
value={viewingHistory ? selectedHistoryId : ""}
disabled={runActive}
onChange={(event) =>
setSelectedHistoryId(event.currentTarget.value)
}
>
<option value="">Current answer</option>
<option value="">Current</option>
{history.map((entry) => (
<option key={entry.id} value={entry.id}>
{historyLabel(entry.request)}
@@ -405,93 +548,142 @@ export default function QuickPane() {
</select>
</label>
)}
<button onClick={dismissPane}>Close</button>
<button
className="quick-pane-close"
type="button"
aria-label="Close Quick Pane"
title="Close Quick Pane (Esc)"
onClick={dismissPane}
>
<span className="full-label">Close</span>
<span className="compact-label" aria-hidden="true">
×
</span>
</button>
</div>
</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>}
<section className="quick-pane-exchange" aria-label="Quick exchange">
{displayedRequest && (
<div className="quick-pane-user-row">
<article className="quick-pane-user-message">
<span className="message-author">You</span>
<p>{displayedRequest}</p>
</article>
</div>
)}
{(viewingHistory
? !!displayedAnswer
: runActive || !!displayedAnswer || displayedAnswerComplete) && (
<article
className="quick-pane-assistant-message"
role="region"
aria-label="Assistant response"
aria-live={viewingHistory ? "off" : "polite"}
aria-busy={!viewingHistory && runActive}
>
{displayedAnswer && (
<>
<span className="message-author">Assistant</span>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayedAnswer}
</ReactMarkdown>
</>
)}
{displayedAnswerComplete && (
<span className="sr-only">Response complete.</span>
)}
</article>
)}
{!viewingHistory && activity && (
<div className="quick-pane-activity" aria-hidden="true">
<span className="typing-dots">
<i />
<i />
<i />
</span>
<span>{activity}</span>
</div>
)}
<span
className="quick-pane-activity-announcement sr-only"
aria-live="polite"
aria-atomic="true"
>
{viewingHistory ? "" : activityPhase}
</span>
{escalation && (
<aside className="quick-pane-notice escalation" role="status">
<div>
<strong>Continue in full session</strong>
<p>{escalation}</p>
</div>
<button type="button" onClick={continueFull}>
Continue
</button>
</aside>
)}
{error && (
<aside className="quick-pane-notice error" role="alert">
<p>{error}</p>
{!!runtime && !escalation && (
<button type="button" onClick={continueFull}>
Continue
</button>
)}
</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>
<footer className="quick-pane-controls">
<label>
<span>Model</span>
<select
aria-label="Model"
value={selectedModelValue}
onChange={(event) => {
const identity = modelIdentityFromValue(
event.currentTarget.value,
);
if (identity) setSelectedModel(identity);
}}
>
<option value="" disabled>
Select model
</option>
{modelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
</label>
<label>
<span>Thinking</span>
<select
aria-label="Thinking"
value={thinkingLevel}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<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={
submissionStarting ||
!settingsLoaded ||
!!invalid ||
!request.trim()
runActive || !settingsLoaded || !!invalid || !request.trim()
}
onClick={run}
>
{submissionStarting ? "Starting…" : "Ask"}
<span aria-hidden="true">{submissionStarting ? "•••" : "↑"}</span>
</button>
{canContinue && (
<button className="quiet" onClick={continueFull}>
Continue
</button>
)}
</footer>
<span className="quick-pane-composer-hint">
Shift+Enter for newline
</span>
</div>
</main>
);
}