fix(quick-pane): restore model catalog controls
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { act, render, screen, waitFor } 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";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
listen: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
onFocusChanged: vi.fn(),
|
||||
onCloseRequested: vi.fn(),
|
||||
focusHandler: undefined as
|
||||
| ((event: { payload: boolean }) => void)
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({
|
||||
hide: mocks.hide,
|
||||
onFocusChanged: mocks.onFocusChanged,
|
||||
onCloseRequested: mocks.onCloseRequested,
|
||||
}),
|
||||
}));
|
||||
|
||||
import QuickPane from "./QuickPane";
|
||||
|
||||
const settings: QuickPaneSettings = {
|
||||
workspace: "/repo",
|
||||
provider: "provider",
|
||||
model: "quick/model",
|
||||
followUpProvider: "provider",
|
||||
followUpModel: "full/model",
|
||||
instructions: "Stay read-only.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
};
|
||||
|
||||
const uiState: UiState = {
|
||||
version: 1,
|
||||
directoryOrder: [],
|
||||
runtimeOrderByDirectory: {},
|
||||
drafts: {},
|
||||
scroll: {},
|
||||
lastSeen: {},
|
||||
quickPane: settings,
|
||||
};
|
||||
|
||||
describe("QuickPane model catalog errors", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.focusHandler = undefined;
|
||||
mocks.listen.mockResolvedValue(vi.fn());
|
||||
mocks.onFocusChanged.mockImplementation((handler) => {
|
||||
mocks.focusHandler = handler;
|
||||
return Promise.resolve(vi.fn());
|
||||
});
|
||||
mocks.onCloseRequested.mockResolvedValue(vi.fn());
|
||||
});
|
||||
|
||||
it("clears a rejected focus reload after success without enabling handoff", async () => {
|
||||
const user = userEvent.setup();
|
||||
let catalogCalls = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
catalogCalls += 1;
|
||||
if (catalogCalls === 2)
|
||||
return Promise.reject(new Error("catalog unavailable"));
|
||||
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({
|
||||
runtime: {},
|
||||
state: { data: { isStreaming: true } },
|
||||
extensions: [],
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
await waitFor(() => expect(catalogCalls).toBe(1));
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Ask a quick question/),
|
||||
"Inspect",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
await screen.findByText(/Quick request running/);
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not load model catalog: Error: catalog unavailable",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Could not load model catalog: Error: catalog unavailable",
|
||||
),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
|
||||
});
|
||||
});
|
||||
+111
-65
@@ -1,8 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExtensionDialog } from "../components/ExtensionDialog";
|
||||
import {
|
||||
catalogModels,
|
||||
getModelCatalog,
|
||||
modelIdentity,
|
||||
modelIdentityFromValue,
|
||||
modelIdentityValue,
|
||||
type ModelIdentity,
|
||||
} from "../model-catalog";
|
||||
import "./quick-pane.css";
|
||||
import type {
|
||||
Extension,
|
||||
@@ -68,9 +76,13 @@ export default function QuickPane() {
|
||||
const [request, setRequest] = useState("");
|
||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||||
provider: "",
|
||||
modelId: "",
|
||||
});
|
||||
const [status, setStatus] = 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 [escalation, setEscalation] = useState<string>();
|
||||
@@ -78,9 +90,26 @@ export default function QuickPane() {
|
||||
const runtimeRef = useRef<Runtime | 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 canContinue = !!escalation || (!!runtime && !!error);
|
||||
|
||||
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;
|
||||
@@ -91,22 +120,18 @@ export default function QuickPane() {
|
||||
if (!active) return;
|
||||
nextSettings = { ...defaults, ...state.quickPane };
|
||||
} catch (reason) {
|
||||
if (active)
|
||||
setError(`Could not load settings: ${String(reason)}`);
|
||||
if (active) setError(`Could not load settings: ${String(reason)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettings(nextSettings);
|
||||
setSettingsLoaded(true);
|
||||
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
||||
setSelectedModel({
|
||||
provider: nextSettings.provider,
|
||||
modelId: nextSettings.model,
|
||||
});
|
||||
setStatus("");
|
||||
try {
|
||||
const catalog = await invoke<{ models?: Model[] }>("get_model_catalog");
|
||||
if (active) setModels(catalog?.models ?? []);
|
||||
} catch (reason) {
|
||||
if (active)
|
||||
setError(`Could not load model catalog: ${String(reason)}`);
|
||||
}
|
||||
if (active) await loadModelCatalog();
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -116,7 +141,7 @@ export default function QuickPane() {
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}, [loadModelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoaded) focusInput();
|
||||
@@ -129,7 +154,10 @@ export default function QuickPane() {
|
||||
(event) => {
|
||||
const nextSettings = { ...defaults, ...event.payload };
|
||||
setSettings(nextSettings);
|
||||
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
||||
setSelectedModel({
|
||||
provider: nextSettings.provider,
|
||||
modelId: nextSettings.model,
|
||||
});
|
||||
},
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -137,7 +165,10 @@ export default function QuickPane() {
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
const focusListener = paneWindow.onFocusChanged((event) => {
|
||||
if (event.payload) focusInput();
|
||||
if (event.payload) {
|
||||
focusInput();
|
||||
void loadModelCatalog();
|
||||
}
|
||||
});
|
||||
const closeListener = paneWindow.onCloseRequested((event) => {
|
||||
event.preventDefault();
|
||||
@@ -149,7 +180,7 @@ export default function QuickPane() {
|
||||
void focusListener.then((unlisten) => unlisten());
|
||||
void closeListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
}, [loadModelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime) return;
|
||||
@@ -231,11 +262,10 @@ export default function QuickPane() {
|
||||
);
|
||||
runtimeRef.current = result.runtime;
|
||||
setRuntime(result.runtime);
|
||||
const [provider, modelId] = selectedModel.split("/", 2);
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
provider: provider || settings.provider,
|
||||
modelId: modelId || settings.model,
|
||||
provider: selectedModel.provider || settings.provider,
|
||||
modelId: selectedModel.modelId || settings.model,
|
||||
});
|
||||
await invoke("set_thinking_level", {
|
||||
agentId: result.runtime.agentId,
|
||||
@@ -296,17 +326,16 @@ export default function QuickPane() {
|
||||
|
||||
return (
|
||||
<main className="quick-pane">
|
||||
<header style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<header>
|
||||
<strong>Quick Pane</strong>
|
||||
<button onClick={dismissPane}>Close</button>
|
||||
</header>
|
||||
<textarea
|
||||
className="quick-pane-request"
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
value={request}
|
||||
onChange={(e) => {
|
||||
setRequest(e.target.value);
|
||||
}}
|
||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
@@ -318,51 +347,68 @@ export default function QuickPane() {
|
||||
}
|
||||
}}
|
||||
placeholder="Ask a quick question or give a quick instruction"
|
||||
style={{ width: "100%", minHeight: 90, marginTop: 14 }}
|
||||
/>
|
||||
<label>
|
||||
Model
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(event) => setSelectedModel(event.currentTarget.value)}
|
||||
>
|
||||
<option value={`${settings.provider}/${settings.model}`}>
|
||||
{settings.provider}/{settings.model}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={`${model.provider}/${model.id}`}
|
||||
value={`${model.provider}/${model.id}`}
|
||||
>
|
||||
{model.provider}/{model.id ?? model.name}
|
||||
<section className="quick-pane-output" aria-live="polite">
|
||||
{status && <p>{status}</p>}
|
||||
{answer && <pre>{answer}</pre>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{catalogError && <p role="alert">{catalogError}</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>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={thinkingLevel}
|
||||
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
||||
{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>
|
||||
<button
|
||||
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
||||
onClick={run}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
||||
onClick={run}
|
||||
>
|
||||
Ask
|
||||
</button>
|
||||
{(escalation || error) && (
|
||||
<button onClick={continueFull}>Continue in full session</button>
|
||||
)}
|
||||
{status && <p>{status}</p>}
|
||||
{answer && <pre style={{ whiteSpace: "pre-wrap" }}>{answer}</pre>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||
Ask
|
||||
</button>
|
||||
{canContinue && (
|
||||
<button className="quiet" onClick={continueFull}>
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -11,11 +18,14 @@ body {
|
||||
|
||||
.quick-pane {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
color: #e6e3df;
|
||||
font: 14px/1.4 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
font: 14px/1.35 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: rgba(34, 34, 34, 0.98);
|
||||
border: 1px solid #4b4b4b;
|
||||
border-radius: 8px;
|
||||
@@ -24,9 +34,9 @@ body {
|
||||
|
||||
.quick-pane header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
color: #f0a347;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
@@ -36,14 +46,15 @@ body {
|
||||
|
||||
.quick-pane button,
|
||||
.quick-pane select {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
padding: 6px 9px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.quick-pane button {
|
||||
margin-right: 6px;
|
||||
flex: 0 0 auto;
|
||||
background: #f0a347;
|
||||
color: #25180b;
|
||||
font-weight: 750;
|
||||
@@ -59,52 +70,108 @@ body {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.quick-pane button.quiet {
|
||||
color: #e6e3df;
|
||||
background: #45413c;
|
||||
}
|
||||
|
||||
.quick-pane select {
|
||||
color: #e6e3df;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.quick-pane textarea {
|
||||
.quick-pane-request {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
min-height: 180px;
|
||||
flex: 1 1 auto;
|
||||
padding: 12px;
|
||||
resize: none;
|
||||
border: 1px solid #484848;
|
||||
border-radius: 6px;
|
||||
color: #e6e3df;
|
||||
background: #292929;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.quick-pane textarea:focus,
|
||||
.quick-pane-request:focus,
|
||||
.quick-pane button:focus-visible,
|
||||
.quick-pane select:focus-visible {
|
||||
outline: 3px solid #f0a347;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.quick-pane > label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 10px 8px 10px 0;
|
||||
color: #bbb7b0;
|
||||
}
|
||||
|
||||
.quick-pane > pre,
|
||||
.quick-pane > p {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 12px;
|
||||
.quick-pane-output {
|
||||
max-height: 108px;
|
||||
min-height: 0;
|
||||
flex: 0 1 auto;
|
||||
overflow: auto;
|
||||
border: 1px solid #484848;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
background: #292929;
|
||||
}
|
||||
|
||||
.quick-pane [role="alert"] {
|
||||
border-color: #a65b67;
|
||||
.quick-pane-output:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-pane-output > pre,
|
||||
.quick-pane-output > p {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.quick-pane-output > :not(:last-child) {
|
||||
border-bottom: 1px solid #484848;
|
||||
}
|
||||
|
||||
.quick-pane-output [role="alert"] {
|
||||
color: #ffd7db;
|
||||
background: #3d252b;
|
||||
}
|
||||
|
||||
.quick-pane-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-pane-controls label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
color: #bbb7b0;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quick-pane-controls label:first-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.quick-pane-controls label:nth-child(2) {
|
||||
flex: 0 1 100px;
|
||||
}
|
||||
|
||||
.quick-pane-controls select {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-pane-output .extension-backdrop,
|
||||
.quick-pane-output .extension {
|
||||
position: static;
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quick-pane-output .extension h2,
|
||||
.quick-pane-output .extension p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user