fix(quick-pane): refresh runtime model catalog

This commit is contained in:
2026-08-26 17:59:31 +02:00
parent 4ec951bc05
commit ad66067a31
11 changed files with 1985 additions and 1265 deletions
File diff suppressed because it is too large Load Diff
+235 -116
View File
@@ -5,11 +5,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog";
import { ModelSelector } from "../components/ModelSelector";
import {
catalogModels,
getModelCatalog,
modelIdentity,
modelIdentityFromValue,
modelIdentityValue,
reconcileModelIdentity,
type ModelIdentity,
@@ -24,6 +22,8 @@ import type {
} from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]";
const pendingQuickRuntimeCleanupIds = new Set<string>();
const pendingQuickRuntimeCleanupPromises = new Map<string, Promise<void>>();
const defaults: QuickPaneSettings = {
workspace: "",
@@ -38,6 +38,7 @@ const defaults: QuickPaneSettings = {
};
type Runtime = { agentId: string; runtimeId: string };
type PreparedRuntime = { runtime: Runtime; models: Model[] };
type HistoryEntry = { id: string; request: string; answer: string };
type ActivitySnapshot = {
activeTool?: string;
@@ -109,6 +110,7 @@ export default function QuickPane() {
const [request, setRequest] = useState("");
const [currentRequest, setCurrentRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState<
@@ -125,7 +127,6 @@ export default function QuickPane() {
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>();
@@ -135,6 +136,12 @@ export default function QuickPane() {
});
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const runtimeRef = useRef<Runtime | undefined>(undefined);
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
const mountedRef = useRef(true);
const requestActiveRef = useRef(false);
const settingsRef = useRef(settings);
settingsRef.current = settings;
const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined
>(undefined);
@@ -142,21 +149,13 @@ export default function QuickPane() {
const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined);
const catalogRequestRef = useRef(0);
const preparationGenerationRef = useRef(0);
const preparationLockRef = useRef(false);
const catalogReadyRef = useRef(false);
const selectedModelRef = useRef(selectedModel);
const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo(() => catalogModels(models, []), [models]);
const selectedModelValue =
selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel)
: "";
const runActive =
submissionStarting ||
(!!runtime &&
completedRuntimeId !== runtime.runtimeId &&
failedRuntimeId !== runtime.runtimeId);
const runActive = submissionStarting || requestActive;
const viewingHistory = !!selectedHistoryId && !runActive;
const displayedEntry = viewingHistory
? history.find((entry) => entry.id === selectedHistoryId)
@@ -179,48 +178,160 @@ export default function QuickPane() {
? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase;
const dismissPane = () => invoke("dismiss_quick_pane");
const focusInput = () => inputRef.current?.focus();
const loadModelCatalog = useCallback(
async (preferredModel?: ModelIdentity) => {
const requestId = ++catalogRequestRef.current;
const closeRuntime = useCallback(async (runtimeId: string) => {
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
if (pending) return pending;
cleanupQueueRef.current.add(runtimeId);
const cleanup = invoke("close_quick_runtime", { runtimeId })
.then(() => {
cleanupQueueRef.current.delete(runtimeId);
})
.finally(() => {
pendingQuickRuntimeCleanupPromises.delete(runtimeId);
});
pendingQuickRuntimeCleanupPromises.set(runtimeId, cleanup);
return cleanup;
}, []);
const clearRuntimeIfCurrent = useCallback((runtimeId: string) => {
if (runtimeRef.current?.runtimeId !== runtimeId) return;
runtimeRef.current = undefined;
preparedRuntimeRef.current = undefined;
setRuntime(undefined);
}, []);
const prepareRuntime = useCallback(
async (preferredModel?: ModelIdentity, ignoreLock = false) => {
if (
requestActiveRef.current ||
(preparationLockRef.current && !ignoreLock)
)
return false;
const generation = ++preparationGenerationRef.current;
catalogReadyRef.current = false;
setCatalogStatus("loading");
setCatalogError(undefined);
setCatalogNotice(undefined);
setModels([]);
try {
const freshModels = await getModelCatalog();
if (requestId !== catalogRequestRef.current) return;
for (const runtimeId of [...cleanupQueueRef.current]) {
await closeRuntime(runtimeId);
clearRuntimeIfCurrent(runtimeId);
}
const previous = runtimeRef.current;
if (previous) {
await closeRuntime(previous.runtimeId);
clearRuntimeIfCurrent(previous.runtimeId);
}
} catch (reason) {
if (generation === preparationGenerationRef.current) {
setCatalogStatus("error");
setCatalogError(`Could not prepare model catalog: ${String(reason)}`);
}
return false;
}
if (generation !== preparationGenerationRef.current) return false;
let created: Runtime | undefined;
let retained = false;
let preparationFailure: unknown;
try {
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
{ worktreePath: settingsRef.current.workspace },
);
created = result.runtime;
if (generation !== preparationGenerationRef.current) return false;
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId: created.runtimeId },
);
if (generation !== preparationGenerationRef.current) return false;
if (snapshot.models?.error) throw new Error(snapshot.models.error);
const freshModels = snapshot.models?.data?.models ?? [];
const preferred = preferredModel ?? selectedModelRef.current;
const reconciled = reconcileModelIdentity(freshModels, preferred);
runtimeRef.current = created;
preparedRuntimeRef.current = { runtime: created, models: freshModels };
setRuntime(created);
setModels(freshModels);
setCatalogError(undefined);
catalogReadyRef.current = true;
setCatalogStatus("ready");
retained = true;
if (!reconciled) {
const emptyModel = { provider: "", modelId: "" };
selectedModelRef.current = emptyModel;
setSelectedModel(emptyModel);
setCatalogStatus("ready");
setCatalogNotice("No models are currently available.");
return;
return false;
}
selectedModelRef.current = reconciled;
setSelectedModel(reconciled);
catalogReadyRef.current = true;
setCatalogStatus("ready");
setCatalogNotice(
modelIdentityValue(reconciled) === modelIdentityValue(preferred)
? undefined
: `Selected model unavailable; using ${reconciled.provider}/${reconciled.modelId}.`,
);
return true;
} catch (reason) {
if (requestId !== catalogRequestRef.current) return;
catalogReadyRef.current = false;
setCatalogStatus("error");
setCatalogError(`Could not load model catalog: ${String(reason)}`);
preparationFailure = reason;
if (generation === preparationGenerationRef.current) {
catalogReadyRef.current = false;
preparedRuntimeRef.current = undefined;
setCatalogStatus("error");
setCatalogError(`Could not prepare model catalog: ${String(reason)}`);
}
return false;
} finally {
if (created && !retained) {
try {
await closeRuntime(created.runtimeId);
} catch (cleanupReason) {
if (generation === preparationGenerationRef.current) {
catalogReadyRef.current = false;
setCatalogStatus("error");
setCatalogError(
preparationFailure
? `Could not prepare model catalog: ${String(preparationFailure)}; cleanup failed: ${String(cleanupReason)}`
: `Could not clean up stale quick runtime: ${String(cleanupReason)}`,
);
} else if (mountedRef.current) {
setCatalogError(
`Could not clean up stale quick runtime: ${String(cleanupReason)}`,
);
}
}
}
}
},
[],
[clearRuntimeIfCurrent, closeRuntime],
);
const closeIdleRuntime = useCallback(async () => {
const current = runtimeRef.current;
if (!current || requestActiveRef.current) return true;
try {
await closeRuntime(current.runtimeId);
clearRuntimeIfCurrent(current.runtimeId);
return true;
} catch (reason) {
setCatalogStatus("error");
setCatalogError(`Could not close Quick runtime: ${String(reason)}`);
return false;
}
}, [clearRuntimeIfCurrent, closeRuntime]);
const dismissPane = useCallback(async () => {
if (await closeIdleRuntime()) await invoke("dismiss_quick_pane");
}, [closeIdleRuntime]);
useEffect(() => {
let active = true;
mountedRef.current = true;
void (async () => {
let nextSettings: QuickPaneSettings;
try {
@@ -232,6 +343,7 @@ export default function QuickPane() {
return;
}
settingsRef.current = nextSettings;
setSettings(nextSettings);
setSettingsLoaded(true);
const configuredModel = {
@@ -241,17 +353,16 @@ export default function QuickPane() {
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
setNotice("");
if (active) await loadModelCatalog(configuredModel);
if (active) await prepareRuntime(configuredModel);
})();
return () => {
active = false;
mountedRef.current = false;
preparationGenerationRef.current += 1;
const current = runtimeRef.current;
if (current)
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
() => undefined,
);
if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
};
}, [loadModelCatalog]);
}, [closeRuntime, prepareRuntime]);
useEffect(() => {
if (settingsLoaded) focusInput();
@@ -279,6 +390,7 @@ export default function QuickPane() {
"quick-pane-settings-saved",
(event) => {
const nextSettings = { ...defaults, ...event.payload };
settingsRef.current = nextSettings;
setSettings(nextSettings);
const configuredModel = {
provider: nextSettings.provider,
@@ -286,17 +398,30 @@ export default function QuickPane() {
};
selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel);
void loadModelCatalog(configuredModel);
if (!requestActiveRef.current && !submissionStartingRef.current)
void prepareRuntime(configuredModel);
},
);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") void dismissPane();
if (event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target;
const selector =
target instanceof Element ? target.closest(".model-selector") : null;
if (
selector?.querySelector(".model-menu") ||
document.querySelector(".model-selector .model-menu")
)
return;
void dismissPane();
};
window.addEventListener("keydown", onKeyDown);
const focusListener = paneWindow.onFocusChanged((event) => {
if (event.payload) {
focusInput();
void loadModelCatalog();
if (!requestActiveRef.current && !submissionStartingRef.current)
void prepareRuntime();
} else if (!submissionStartingRef.current) {
void closeIdleRuntime();
}
});
return () => {
@@ -304,10 +429,10 @@ export default function QuickPane() {
void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten());
};
}, [loadModelCatalog]);
}, [closeIdleRuntime, dismissPane, prepareRuntime]);
useEffect(() => {
if (!runtime) return;
if (!runtime || !requestActive) return;
let active = true;
const refresh = async () => {
try {
@@ -347,6 +472,8 @@ export default function QuickPane() {
completedRuntimeRef.current !== runtime.runtimeId
) {
completedRuntimeRef.current = runtime.runtimeId;
requestActiveRef.current = false;
setRequestActive(false);
setCompletedRuntimeId(runtime.runtimeId);
setNotice("");
const submitted = submittedRequestRef.current;
@@ -370,7 +497,8 @@ export default function QuickPane() {
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
setFailedRuntimeId(runtime.runtimeId);
requestActiveRef.current = false;
setRequestActive(false);
setEscalation(
"Quick runtime failed. Continue in a full session if needed.",
);
@@ -386,84 +514,94 @@ export default function QuickPane() {
active = false;
window.clearInterval(interval);
};
}, [runtime]);
}, [requestActive, runtime]);
useEffect(() => {
if (!runtime || completedRuntimeId === runtime.runtimeId) return;
if (!runtime || !requestActive) return;
const timeout = window.setTimeout(() => {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
setFailedRuntimeId(runtime.runtimeId);
requestActiveRef.current = false;
setRequestActive(false);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
}, [requestActive, runtime, settings.maximumSeconds]);
async function run() {
if (submissionStartingRef.current || runActive) return;
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
return setError("Model catalog is not ready.");
if (submissionStartingRef.current || requestActiveRef.current) return;
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
return setError("Model catalog is not ready.");
submissionStartingRef.current = true;
preparationLockRef.current = true;
setSubmissionStarting(true);
const submittedRequest = request;
const submittedModel = selectedModelRef.current;
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;
if (!preparedRuntimeRef.current) {
const prepared = await prepareRuntime(selectedModelRef.current, true);
if (!prepared) throw new Error("Model catalog is not ready.");
}
const prepared = preparedRuntimeRef.current;
const submittedModel = selectedModelRef.current;
if (
!prepared ||
!prepared.models.some((model) => {
const identity = modelIdentity(model);
return (
identity?.provider === submittedModel.provider &&
identity.modelId === submittedModel.modelId
);
})
)
throw new Error("Selected model is not in prepared runtime catalog.");
const submittedRuntime = prepared.runtime;
preparedRuntimeRef.current = undefined;
submittedRequestRef.current = {
runtimeId: submittedRuntime.runtimeId,
request: submittedRequest,
};
promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined;
setRuntime(undefined);
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
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);
runStartedAtRef.current = Date.now();
setElapsedSeconds(0);
requestActiveRef.current = true;
setRequestActive(true);
await invoke("set_model", {
agentId: result.runtime.agentId,
agentId: submittedRuntime.agentId,
provider: submittedModel.provider,
modelId: submittedModel.modelId,
});
await invoke("set_thinking_level", {
agentId: result.runtime.agentId,
agentId: submittedRuntime.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,
agentId: submittedRuntime.agentId,
message: guardrails,
});
promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
} catch (reason) {
requestActiveRef.current = false;
setRequestActive(false);
setError(`Could not start quick request: ${String(reason)}`);
setFailedRuntimeId(runtimeRef.current?.runtimeId);
} finally {
preparationLockRef.current = false;
submissionStartingRef.current = false;
setSubmissionStarting(false);
focusInput();
@@ -516,42 +654,23 @@ export default function QuickPane() {
<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) {
selectedModelRef.current = 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>
<ModelSelector
className="quick-pane-model-selector"
placement="down"
models={models}
current={{
provider: selectedModel.provider,
id: selectedModel.modelId,
}}
disabled={runActive || catalogStatus !== "ready"}
onSelect={async (model) => {
const identity = modelIdentity(model);
if (!identity) return false;
selectedModelRef.current = identity;
setSelectedModel(identity);
return true;
}}
/>
<label className="quick-pane-field quick-pane-thinking">
<span className="full-label">Thinking</span>
<span className="compact-label" aria-hidden="true">
+46 -4
View File
@@ -22,7 +22,7 @@ body {
flex-direction: column;
gap: 9px;
height: 100vh;
overflow: hidden;
overflow: visible;
padding: 10px;
color: #e6e3df;
font:
@@ -58,13 +58,11 @@ body {
.quick-pane-primary-controls {
flex: 1 1 auto;
overflow: hidden;
}
.quick-pane-header-actions {
flex: 0 1 auto;
justify-content: end;
overflow: hidden;
}
.quick-pane-field {
@@ -76,9 +74,53 @@ body {
text-transform: uppercase;
}
.quick-pane-model {
.quick-pane-model-selector {
flex: 1 1 230px;
max-width: 310px;
color: #bbb7b0;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quick-pane .model-trigger {
height: 27px;
border: 0;
padding: 5px 25px 5px 8px;
color: #e6e3df;
background-color: #353535;
font-size: 11px;
font-weight: 500;
}
.quick-pane .model-menu {
width: min(400px, calc(100vw - 24px));
max-height: min(350px, calc(100vh - 56px));
color: #e6e3df;
font-size: 11px;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
}
.quick-pane .model-option {
display: grid;
padding: 6px 7px;
border: 1px solid transparent;
color: #f2ece4;
background: #35322e;
font-size: 11px;
font-weight: 500;
}
.quick-pane .model-option:hover {
border-color: #8c6338;
background: #44382d;
}
.quick-pane .model-option[aria-selected="true"] {
border-color: #f0a347;
background: #4b3420;
}
.quick-pane-thinking {