From 5642d9be914f797f1c7cb9d7f33091110d962553 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 28 Aug 2026 14:14:46 +0200 Subject: [PATCH] feat(quick-pane): persist default thinking level --- test/quick-pane-ui.test.js | 4 +- ui/src-tauri/src/ui_state.rs | 25 ++++++++++++- ui/src/App.test.tsx | 16 ++++++++ ui/src/App.tsx | 52 +++++++++++++++++++++++++- ui/src/components/ThinkingSelector.tsx | 6 ++- ui/src/quick-pane/QuickPane.test.tsx | 41 ++++++++++++++++++-- ui/src/quick-pane/QuickPane.tsx | 15 ++++++-- ui/src/quick-pane/quick-pane.css | 29 ++++++++------ ui/src/thinking-parity.test.tsx | 1 + ui/src/workspace/types.ts | 1 + 10 files changed, 164 insertions(+), 26 deletions(-) diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index 773de76..6f15c7c 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -278,7 +278,7 @@ test("Quick Pane uses native dimensions and request-first compact layout", async assert.match(source, /new ResizeObserver\(resizeToContent\)/); assert.match(source, /invoke\("resize_quick_pane", \{ height \}\)/); assert.doesNotMatch(source, /onCloseRequested/); - assert.match(source, /className="quick-pane-drag-strip"/); + assert.match(source, /className="quick-pane-drag-region"/); assert.match(source, /startDragging\(\)/); assert.match(source, /className="quick-pane-request"/); assert.match(source, /className=\{`quick-pane-exchange/); @@ -322,7 +322,7 @@ test("Quick Pane uses native dimensions and request-first compact layout", async stylesheet, /\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/, ); - assert.match(stylesheet, /\.quick-pane-drag-strip[\s\S]*?height: 8px/); + assert.match(stylesheet, /\.quick-pane-drag-region[\s\S]*?height: 24px/); assert.match( stylesheet, /\.quick-pane-composer-actions[\s\S]*?position: absolute/, diff --git a/ui/src-tauri/src/ui_state.rs b/ui/src-tauri/src/ui_state.rs index daa2521..c332a35 100644 --- a/ui/src-tauri/src/ui_state.rs +++ b/ui/src-tauri/src/ui_state.rs @@ -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(); } diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 967b805..e69890f 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -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(); @@ -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(); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 302d495..25d1209 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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(); const [catalogWorkspace, setCatalogWorkspace] = useState(); 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()); const retainedCatalogProbeRuntimeIds = useRef(new Set()); const retainedCatalogProbeErrors = useRef(new Map()); @@ -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; }} /> Provider comes from selected catalog model. +
+ + setQuickPaneSettings((current) => ({ + ...current, + defaultThinkingLevel, + })) + } + /> + Used when a Quick Pane opens. +
- Thinking + {label}