690 lines
23 KiB
TypeScript
690 lines
23 KiB
TypeScript
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,
|
||
getModelCatalog,
|
||
modelIdentity,
|
||
modelIdentityFromValue,
|
||
modelIdentityValue,
|
||
type ModelIdentity,
|
||
} from "../model-catalog";
|
||
import "./quick-pane.css";
|
||
import type {
|
||
Extension,
|
||
Model,
|
||
QuickPaneSettings,
|
||
RuntimeSnapshot,
|
||
UiState,
|
||
} from "../workspace/types";
|
||
|
||
const ESCALATION_MARKER = "[ESCALATE]";
|
||
|
||
const defaults: QuickPaneSettings = {
|
||
workspace: "",
|
||
provider: "",
|
||
model: "",
|
||
followUpProvider: "",
|
||
followUpModel: "",
|
||
instructions:
|
||
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
|
||
maximumSeconds: 60,
|
||
maximumToolCalls: 6,
|
||
};
|
||
|
||
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();
|
||
return concise.length > 42 ? `${concise.slice(0, 39)}…` : concise;
|
||
}
|
||
|
||
function settingsError(settings: QuickPaneSettings) {
|
||
if (!settings.workspace.startsWith("/"))
|
||
return "Workspace must be an absolute path.";
|
||
if (!settings.provider || !settings.model)
|
||
return "Quick provider and model are required.";
|
||
if (!settings.followUpProvider || !settings.followUpModel)
|
||
return "Follow-up provider and model are required.";
|
||
if (!settings.instructions.trim())
|
||
return "Supplemental instructions are required.";
|
||
if (
|
||
!Number.isInteger(settings.maximumSeconds) ||
|
||
settings.maximumSeconds < 1 ||
|
||
settings.maximumSeconds > 600
|
||
)
|
||
return "Seconds must be an integer from 1 to 600.";
|
||
if (
|
||
!Number.isInteger(settings.maximumToolCalls) ||
|
||
settings.maximumToolCalls < 1 ||
|
||
settings.maximumToolCalls > 64
|
||
)
|
||
return "Tool calls must be an integer from 1 to 64.";
|
||
}
|
||
|
||
function assistantText(snapshot: RuntimeSnapshot) {
|
||
const messages = snapshot.transcript?.data?.messages ?? [];
|
||
const message = [...messages]
|
||
.reverse()
|
||
.find((candidate) => candidate.role === "assistant");
|
||
if (!message) return "";
|
||
if (typeof message.content === "string") return message.content;
|
||
return (message.content ?? [])
|
||
.map((part) => part.text ?? "")
|
||
.filter(Boolean)
|
||
.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[]>([]);
|
||
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||
provider: "",
|
||
modelId: "",
|
||
});
|
||
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(
|
||
() => catalogModels(models, [selectedModel]),
|
||
[models, selectedModel],
|
||
);
|
||
const selectedModelValue =
|
||
selectedModel.provider && selectedModel.modelId
|
||
? modelIdentityValue(selectedModel)
|
||
: "";
|
||
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();
|
||
const loadModelCatalog = useCallback(async () => {
|
||
try {
|
||
setModels(await getModelCatalog());
|
||
setCatalogError(undefined);
|
||
} catch (reason) {
|
||
setCatalogError(`Could not load model catalog: ${String(reason)}`);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
void (async () => {
|
||
let nextSettings: QuickPaneSettings;
|
||
try {
|
||
const state = await invoke<UiState>("load_ui_state");
|
||
if (!active) return;
|
||
nextSettings = { ...defaults, ...state.quickPane };
|
||
} catch (reason) {
|
||
if (active) setError(`Could not load settings: ${String(reason)}`);
|
||
return;
|
||
}
|
||
|
||
setSettings(nextSettings);
|
||
setSettingsLoaded(true);
|
||
setSelectedModel({
|
||
provider: nextSettings.provider,
|
||
modelId: nextSettings.model,
|
||
});
|
||
setNotice("");
|
||
if (active) await loadModelCatalog();
|
||
})();
|
||
return () => {
|
||
active = false;
|
||
const current = runtimeRef.current;
|
||
if (current)
|
||
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
|
||
() => undefined,
|
||
);
|
||
};
|
||
}, [loadModelCatalog]);
|
||
|
||
useEffect(() => {
|
||
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>(
|
||
"quick-pane-settings-saved",
|
||
(event) => {
|
||
const nextSettings = { ...defaults, ...event.payload };
|
||
setSettings(nextSettings);
|
||
setSelectedModel({
|
||
provider: nextSettings.provider,
|
||
modelId: nextSettings.model,
|
||
});
|
||
},
|
||
);
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") void dismissPane();
|
||
};
|
||
window.addEventListener("keydown", onKeyDown);
|
||
const focusListener = paneWindow.onFocusChanged((event) => {
|
||
if (event.payload) {
|
||
focusInput();
|
||
void loadModelCatalog();
|
||
}
|
||
});
|
||
return () => {
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
void settingsListener.then((unlisten) => unlisten());
|
||
void focusListener.then((unlisten) => unlisten());
|
||
};
|
||
}, [loadModelCatalog]);
|
||
|
||
useEffect(() => {
|
||
if (!runtime) return;
|
||
let active = true;
|
||
const refresh = async () => {
|
||
try {
|
||
const snapshot = await invoke<RuntimeSnapshot>(
|
||
"get_session_runtime_snapshot",
|
||
{
|
||
runtimeId: runtime.runtimeId,
|
||
},
|
||
);
|
||
if (!active) return;
|
||
const text = assistantText(snapshot);
|
||
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),
|
||
);
|
||
}
|
||
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.",
|
||
);
|
||
}
|
||
} catch (reason) {
|
||
if (active)
|
||
setError(`Could not refresh quick result: ${String(reason)}`);
|
||
}
|
||
};
|
||
void refresh();
|
||
const interval = window.setInterval(() => void refresh(), 800);
|
||
return () => {
|
||
active = false;
|
||
window.clearInterval(interval);
|
||
};
|
||
}, [runtime]);
|
||
|
||
useEffect(() => {
|
||
if (!runtime || completedRuntimeId === runtime.runtimeId) return;
|
||
const timeout = window.setTimeout(() => {
|
||
setEscalation(
|
||
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
|
||
);
|
||
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 || 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)
|
||
await invoke("close_quick_runtime", {
|
||
runtimeId: previous.runtimeId,
|
||
}).catch(() => undefined);
|
||
runtimeRef.current = undefined;
|
||
submittedRequestRef.current = undefined;
|
||
promptSubmittedRuntimeRef.current = undefined;
|
||
completedRuntimeRef.current = undefined;
|
||
setRuntime(undefined);
|
||
setCompletedRuntimeId(undefined);
|
||
setFailedRuntimeId(undefined);
|
||
setError(undefined);
|
||
setAnswer("");
|
||
setEscalation(undefined);
|
||
setExtension(undefined);
|
||
setActivitySnapshot({ hasAssistantText: false });
|
||
setNotice("");
|
||
const result = await invoke<{ runtime: Runtime }>(
|
||
"create_quick_runtime",
|
||
{
|
||
worktreePath: settings.workspace,
|
||
},
|
||
);
|
||
runtimeRef.current = result.runtime;
|
||
submittedRequestRef.current = {
|
||
runtimeId: result.runtime.runtimeId,
|
||
request: submittedRequest,
|
||
};
|
||
setRuntime(result.runtime);
|
||
await invoke("set_model", {
|
||
agentId: result.runtime.agentId,
|
||
provider: selectedModel.provider || settings.provider,
|
||
modelId: selectedModel.modelId || settings.model,
|
||
});
|
||
await invoke("set_thinking_level", {
|
||
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: ${submittedRequest}`;
|
||
await invoke("submit_prompt", {
|
||
agentId: result.runtime.agentId,
|
||
message: guardrails,
|
||
});
|
||
promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
|
||
} catch (reason) {
|
||
setError(`Could not start quick request: ${String(reason)}`);
|
||
setFailedRuntimeId(runtimeRef.current?.runtimeId);
|
||
} finally {
|
||
submissionStartingRef.current = false;
|
||
setSubmissionStarting(false);
|
||
focusInput();
|
||
window.requestAnimationFrame(focusInput);
|
||
}
|
||
}
|
||
|
||
async function respond(response: Record<string, unknown>) {
|
||
if (!runtime || !extension?.id) return;
|
||
try {
|
||
await invoke("respond_to_extension", {
|
||
agentId: runtime.agentId,
|
||
requestId: extension.id,
|
||
response,
|
||
});
|
||
setExtension(undefined);
|
||
} catch (reason) {
|
||
setError(`Could not answer Pi request: ${String(reason)}`);
|
||
}
|
||
}
|
||
|
||
async function continueFull() {
|
||
const handoffRequest =
|
||
request.trim() || submittedRequestRef.current?.request || currentRequest;
|
||
if (invalid || !handoffRequest)
|
||
return setError(invalid ?? "Enter a request.");
|
||
try {
|
||
const result = await invoke<{ runtime: Runtime }>(
|
||
"create_session_runtime",
|
||
{
|
||
worktreePath: settings.workspace,
|
||
},
|
||
);
|
||
await invoke("set_model", {
|
||
agentId: result.runtime.agentId,
|
||
provider: settings.followUpProvider,
|
||
modelId: settings.followUpModel,
|
||
});
|
||
await invoke("submit_prompt", {
|
||
agentId: result.runtime.agentId,
|
||
message: `${handoffRequest}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
|
||
});
|
||
setNotice("Full session started with follow-up model.");
|
||
} catch (reason) {
|
||
setError(`Could not start full session: ${String(reason)}`);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="quick-pane">
|
||
<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-field quick-pane-history">
|
||
<span className="full-label">History</span>
|
||
<span className="compact-label" aria-hidden="true">
|
||
H
|
||
</span>
|
||
<select
|
||
aria-label="Answer history"
|
||
value={viewingHistory ? selectedHistoryId : ""}
|
||
disabled={runActive}
|
||
onChange={(event) =>
|
||
setSelectedHistoryId(event.currentTarget.value)
|
||
}
|
||
>
|
||
<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>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="quick-pane-exchange" aria-label="Quick exchange">
|
||
{displayedRequest && (
|
||
<div className="quick-pane-user-row">
|
||
<article className="quick-pane-user-message">
|
||
<span className="message-author">You</span>
|
||
<p>{displayedRequest}</p>
|
||
</article>
|
||
</div>
|
||
)}
|
||
{(viewingHistory
|
||
? !!displayedAnswer
|
||
: runActive || !!displayedAnswer || displayedAnswerComplete) && (
|
||
<article
|
||
className="quick-pane-assistant-message"
|
||
role="region"
|
||
aria-label="Assistant response"
|
||
aria-live={viewingHistory ? "off" : "polite"}
|
||
aria-busy={!viewingHistory && runActive}
|
||
>
|
||
{displayedAnswer && (
|
||
<>
|
||
<span className="message-author">Assistant</span>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||
{displayedAnswer}
|
||
</ReactMarkdown>
|
||
</>
|
||
)}
|
||
{displayedAnswerComplete && (
|
||
<span className="sr-only">Response complete.</span>
|
||
)}
|
||
</article>
|
||
)}
|
||
{!viewingHistory && activity && (
|
||
<div className="quick-pane-activity" aria-hidden="true">
|
||
<span className="typing-dots">
|
||
<i />
|
||
<i />
|
||
<i />
|
||
</span>
|
||
<span>{activity}</span>
|
||
</div>
|
||
)}
|
||
<span
|
||
className="quick-pane-activity-announcement sr-only"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
>
|
||
{viewingHistory ? "" : activityPhase}
|
||
</span>
|
||
{escalation && (
|
||
<aside className="quick-pane-notice escalation" role="status">
|
||
<div>
|
||
<strong>Continue in full session</strong>
|
||
<p>{escalation}</p>
|
||
</div>
|
||
<button type="button" onClick={continueFull}>
|
||
Continue
|
||
</button>
|
||
</aside>
|
||
)}
|
||
{error && (
|
||
<aside className="quick-pane-notice error" role="alert">
|
||
<p>{error}</p>
|
||
{!!runtime && !escalation && (
|
||
<button type="button" onClick={continueFull}>
|
||
Continue
|
||
</button>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|