feat(quick-pane): persist default thinking level

This commit is contained in:
2026-08-28 14:14:46 +02:00
parent 1ce03ef986
commit 5642d9be91
10 changed files with 164 additions and 26 deletions
+24 -1
View File
@@ -12,6 +12,7 @@ const MAX_DRAFT_BYTES: usize = 128 * 1024;
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
const DEFAULT_THINKING_LEVEL: &str = "medium";
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -47,6 +48,12 @@ pub struct QuickPaneSettings {
pub follow_up_model: String,
pub instructions: String,
pub maximum_tool_calls: u32,
#[serde(default = "default_thinking_level")]
pub default_thinking_level: String,
}
fn default_thinking_level() -> String {
DEFAULT_THINKING_LEVEL.to_owned()
}
impl Default for QuickPaneSettings {
@@ -59,6 +66,7 @@ impl Default for QuickPaneSettings {
follow_up_model: String::new(),
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.".to_owned(),
maximum_tool_calls: 6,
default_thinking_level: default_thinking_level(),
}
}
}
@@ -233,6 +241,12 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
if !(1..=64).contains(&quick.maximum_tool_calls) {
return Err("Quick-pane tool-call limit is outside the supported range".to_owned());
}
if !matches!(
quick.default_thinking_level.as_str(),
"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
) {
return Err("Quick-pane default thinking level is invalid".to_owned());
}
total_string_bytes = total_string_bytes
.saturating_add(quick.workspace.len())
.saturating_add(quick.provider.len())
@@ -449,6 +463,12 @@ mod tests {
state.quick_pane.follow_up_provider = "provider".into();
state.quick_pane.follow_up_model = "follow-up-model".into();
assert!(validate(&state).is_ok());
state.quick_pane.default_thinking_level = "high".into();
save_to(&root, &state).unwrap();
assert_eq!(load_from(&root).unwrap().quick_pane.default_thinking_level, "high");
state.quick_pane.default_thinking_level = "unsupported".into();
assert!(validate(&state).is_err());
state.quick_pane.default_thinking_level = default_thinking_level();
state.quick_pane.maximum_tool_calls = 0;
assert!(validate(&state).is_err());
fs::remove_dir_all(root).unwrap();
@@ -465,8 +485,11 @@ mod tests {
)
.unwrap();
let state = load_from(&root).unwrap();
assert_eq!(state.quick_pane.default_thinking_level, DEFAULT_THINKING_LEVEL);
save_to(&root, &state).unwrap();
assert!(!fs::read_to_string(&path).unwrap().contains("maximumSeconds"));
let saved = fs::read_to_string(&path).unwrap();
assert!(!saved.contains("maximumSeconds"));
assert!(saved.contains("\"defaultThinkingLevel\": \"medium\""));
fs::remove_dir_all(root).unwrap();
}
+16
View File
@@ -147,6 +147,7 @@ describe("App command progress and errors", () => {
followUpModel: "gpt-5",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
mocks.invoke.mockImplementation((command: string) => {
@@ -189,6 +190,10 @@ describe("App command progress and errors", () => {
await user.click(
screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }),
);
await user.selectOptions(
screen.getByRole("combobox", { name: "Default thinking" }),
"high",
);
await user.click(
screen.getByRole("button", { name: "Save Quick Pane settings" }),
);
@@ -200,6 +205,7 @@ describe("App command progress and errors", () => {
...loaded.quickPane,
provider: "openrouter",
model: "anthropic/claude/sonnet",
defaultThinkingLevel: "high",
},
},
}),
@@ -222,6 +228,7 @@ describe("App command progress and errors", () => {
followUpModel: "old-model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
});
if (command === "create_quick_runtime") {
@@ -283,6 +290,7 @@ describe("App command progress and errors", () => {
followUpModel: "old-model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
const first = deferred<{
@@ -357,6 +365,7 @@ describe("App command progress and errors", () => {
followUpModel: "old-model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
const staleSnapshot = deferred<{
@@ -450,6 +459,7 @@ describe("App command progress and errors", () => {
followUpModel: "model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
const snapshot = deferred<unknown>();
@@ -487,6 +497,7 @@ describe("App command progress and errors", () => {
followUpModel: "old-model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
let openCount = 0;
@@ -546,6 +557,7 @@ describe("App command progress and errors", () => {
followUpModel: "old-model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
let runtimeCount = 0;
@@ -620,6 +632,7 @@ describe("App command progress and errors", () => {
followUpModel: "old-model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
const oldSnapshot = deferred<{
@@ -693,6 +706,7 @@ describe("App command progress and errors", () => {
followUpModel: "model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
let runtimeCount = 0;
@@ -764,6 +778,7 @@ describe("App command progress and errors", () => {
followUpModel: "model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
let createCount = 0;
@@ -819,6 +834,7 @@ describe("App command progress and errors", () => {
followUpModel: "model",
instructions: "Stay concise.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
const nextSnapshot = deferred<unknown>();
+50 -2
View File
@@ -6,13 +6,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./App.css";
import { ConversationWorkspace } from "./components/ConversationWorkspace";
import { ModelSelector } from "./components/ModelSelector";
import { ThinkingSelector } from "./components/ThinkingSelector";
import { DirectorySidebar } from "./components/DirectorySidebar";
import { ExtensionDialog } from "./components/ExtensionDialog";
import { CommandFormDialog } from "./components/CommandFormDialog";
import type { RpcCommand } from "./commands/rpc";
import { SessionPicker } from "./components/SessionPicker";
import { SessionTabs } from "./components/SessionTabs";
import { modelIdentity, reconcileModelIdentity } from "./model-catalog";
import {
modelIdentity,
reconcileModelIdentity,
reconcileThinkingLevel,
} from "./model-catalog";
import type {
DirectorySession,
DirectoryWorkspace,
@@ -37,6 +42,7 @@ const defaultQuickPaneSettings: QuickPaneSettings = {
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
};
function validateQuickPaneSettings(settings: QuickPaneSettings) {
if (!settings.workspace.startsWith("/"))
@@ -53,6 +59,8 @@ function validateQuickPaneSettings(settings: QuickPaneSettings) {
settings.maximumToolCalls > 64
)
return "Tool calls must be an integer from 1 to 64.";
if (!settings.defaultThinkingLevel)
return "Default thinking level is required.";
}
let submissionId = 0;
type LocalOperation = {
@@ -164,6 +172,15 @@ export default function App() {
const [modelCatalogError, setModelCatalogError] = useState<string>();
const [catalogWorkspace, setCatalogWorkspace] = useState<string>();
const modelCatalogRequest = useRef(0);
const quickPaneModel = useMemo(
() =>
modelCatalog.find(
(model) =>
model.provider === quickPaneSettings.provider &&
model.id === quickPaneSettings.model,
),
[modelCatalog, quickPaneSettings.model, quickPaneSettings.provider],
);
const catalogProbeRuntimeIds = useRef(new Set<string>());
const retainedCatalogProbeRuntimeIds = useRef(new Set<string>());
const retainedCatalogProbeErrors = useRef(new Map<string, unknown>());
@@ -291,18 +308,30 @@ export default function App() {
provider: current.followUpProvider,
modelId: current.followUpModel,
});
const selectedQuickModel = freshModels.find(
(model) =>
model.provider === quickModel?.provider &&
model.id === quickModel?.modelId,
);
const next = {
...current,
provider: quickModel?.provider ?? "",
model: quickModel?.modelId ?? "",
followUpProvider: followUpModel?.provider ?? "",
followUpModel: followUpModel?.modelId ?? "",
defaultThinkingLevel: selectedQuickModel
? reconcileThinkingLevel(
selectedQuickModel,
current.defaultThinkingLevel,
)
: current.defaultThinkingLevel,
};
if (
next.provider === current.provider &&
next.model === current.model &&
next.followUpProvider === current.followUpProvider &&
next.followUpModel === current.followUpModel
next.followUpModel === current.followUpModel &&
next.defaultThinkingLevel === current.defaultThinkingLevel
)
return current;
setQuickPaneSettingsStatus(
@@ -1082,12 +1111,31 @@ export default function App() {
...current,
provider: identity.provider,
model: identity.modelId,
defaultThinkingLevel: reconcileThinkingLevel(
model,
current.defaultThinkingLevel,
),
}));
return true;
}}
/>
<small>Provider comes from selected catalog model.</small>
</div>
<div className="quick-pane-model-setting">
<ThinkingSelector
label="Default thinking"
model={quickPaneModel}
value={quickPaneSettings.defaultThinkingLevel}
disabled={modelCatalogStatus !== "ready" || !quickPaneModel}
onChange={(defaultThinkingLevel) =>
setQuickPaneSettings((current) => ({
...current,
defaultThinkingLevel,
}))
}
/>
<small>Used when a Quick Pane opens.</small>
</div>
<div className="quick-pane-model-setting">
<ModelSelector
label="Follow-up model"
+4 -2
View File
@@ -4,6 +4,7 @@ import "./ThinkingSelector.css";
type ThinkingSelectorProps = {
model?: Model;
label?: string;
value: string;
disabled?: boolean;
className?: string;
@@ -13,6 +14,7 @@ type ThinkingSelectorProps = {
export function ThinkingSelector({
model,
label = "Thinking",
value,
disabled = false,
className,
@@ -24,10 +26,10 @@ export function ThinkingSelector({
return (
<label className={classes}>
<span>Thinking</span>
<span>{label}</span>
<select
className={controlClassName}
aria-label="Thinking"
aria-label={label}
value={selected}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
+37 -4
View File
@@ -45,6 +45,7 @@ const settings: QuickPaneSettings = {
followUpModel: "full/model",
instructions: "Stay read-only.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
};
const uiState: UiState = {
version: 1,
@@ -112,6 +113,7 @@ function bridgeMock(
activeSnapshot?: (
index: number,
) => RuntimeSnapshot | Promise<RuntimeSnapshot>,
loadedState = uiState,
) {
let runtimeCount = 0;
const submitted = new Set<string>();
@@ -121,7 +123,7 @@ function bridgeMock(
command: string,
payload?: { runtimeId?: string; agentId?: string; message?: string },
) => {
if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "load_ui_state") return Promise.resolve(loadedState);
if (command === "create_quick_runtime") {
runtimeCount += 1;
return Promise.resolve({
@@ -232,7 +234,7 @@ describe("QuickPane", () => {
expect(
Array.from(pane?.children ?? []).map((element) => element.classList[0]),
).toEqual([
"quick-pane-drag-strip",
"quick-pane-drag-region",
"quick-pane-toolbar",
"quick-pane-composer",
"quick-pane-exchange",
@@ -278,15 +280,18 @@ describe("QuickPane", () => {
}
});
it("starts native dragging only from the strip above toolbar controls", async () => {
it("starts native dragging from whole visible region, never toolbar controls", async () => {
bridgeMock([defaultModels]);
const { container } = render(<QuickPane />);
await preparedTrigger();
fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!);
const dragRegion = container.querySelector(".quick-pane-drag-region")!;
expect(dragRegion.querySelector(".quick-pane-drag-grip")).toBeTruthy();
fireEvent.pointerDown(dragRegion);
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(
screen.getByRole("button", { name: "Close Quick Pane" }),
);
fireEvent.pointerDown(screen.getByRole("combobox", { name: "Thinking" }));
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
});
@@ -422,6 +427,33 @@ describe("QuickPane", () => {
expect(input.selectionDirection).toBe("none");
});
it("applies persisted default thinking level when supported", async () => {
const thinkingModels: Model[] = [
{
provider: "provider",
id: "quick/model",
reasoning: true,
thinkingLevelMap: { high: "high" },
},
];
bridgeMock([thinkingModels], undefined, {
...uiState,
quickPane: { ...settings, defaultThinkingLevel: "high" },
});
render(<QuickPane />);
await preparedTrigger();
expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveValue(
"high",
);
await ask("Inspect");
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", {
agentId: "agent-1",
level: "high",
}),
);
});
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
const thinkingModels: Model[] = [
{
@@ -448,6 +480,7 @@ describe("QuickPane", () => {
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "High"]);
expect(thinking).toHaveValue("off");
await userEvent.selectOptions(thinking, "high");
await ask("Inspect");
await waitFor(() =>
+12 -3
View File
@@ -39,6 +39,7 @@ const defaults: QuickPaneSettings = {
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
};
type Runtime = { agentId: string; runtimeId: string };
@@ -170,6 +171,8 @@ function settingsError(settings: QuickPaneSettings) {
settings.maximumToolCalls > 64
)
return "Tool calls must be an integer from 1 to 64.";
if (!settings.defaultThinkingLevel)
return "Default thinking level is required.";
}
function currentTurnAssistantText(
@@ -223,7 +226,9 @@ export default function QuickPane() {
const [requestActive, setRequestActive] = useState(false);
const [chatReplacing, setChatReplacing] = useState(false);
const [promoting, setPromoting] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [thinkingLevel, setThinkingLevel] = useState(
defaults.defaultThinkingLevel,
);
const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState<
"loading" | "ready" | "error"
@@ -605,6 +610,7 @@ export default function QuickPane() {
const nextSettings = { ...defaults, ...state.quickPane };
settingsRef.current = nextSettings;
setSettings(nextSettings);
setThinkingLevel(nextSettings.defaultThinkingLevel);
setSettingsLoaded(true);
const configuredModel = {
provider: nextSettings.provider,
@@ -815,6 +821,7 @@ export default function QuickPane() {
const nextSettings = { ...defaults, ...event.payload };
settingsRef.current = nextSettings;
setSettings(nextSettings);
setThinkingLevel(nextSettings.defaultThinkingLevel);
const configuredModel = {
provider: nextSettings.provider,
modelId: nextSettings.model,
@@ -1129,14 +1136,16 @@ export default function QuickPane() {
return (
<main className="quick-pane" ref={paneRef}>
<div
className="quick-pane-drag-strip"
className="quick-pane-drag-region"
aria-hidden="true"
onPointerDown={() =>
void getCurrentWindow()
.startDragging()
.catch(() => undefined)
}
/>
>
<span className="quick-pane-drag-grip" />
</div>
<header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls">
<ModelSelector
+17 -12
View File
@@ -47,11 +47,13 @@ body {
align-items: center;
}
.quick-pane-drag-strip {
.quick-pane-drag-region {
position: relative;
height: 8px;
flex: 0 0 8px;
cursor: move;
display: grid;
height: 24px;
flex: 0 0 24px;
place-items: center;
cursor: grab;
touch-action: none;
border-bottom: 1px solid #70522f;
background: linear-gradient(
@@ -62,17 +64,20 @@ body {
);
}
.quick-pane-drag-strip::after {
position: absolute;
top: 2px;
left: 50%;
.quick-pane-drag-region:active {
cursor: grabbing;
}
.quick-pane-drag-grip {
width: 44px;
height: 3px;
height: 5px;
border-radius: 999px;
background: #f0a347;
background: repeating-linear-gradient(
90deg,
#f0a347 0 4px,
transparent 4px 7px
);
box-shadow: 0 0 8px rgba(240, 163, 71, 0.45);
content: "";
transform: translateX(-50%);
}
.quick-pane-toolbar {
+1
View File
@@ -36,6 +36,7 @@ const settings: UiState = {
followUpModel: "model",
instructions: "Stay read-only.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
+1
View File
@@ -239,6 +239,7 @@ export type QuickPaneSettings = {
followUpModel: string;
instructions: string;
maximumToolCalls: number;
defaultThinkingLevel: string;
};
export type UiState = {