From 880cddf3cf4efc6c17baffda765342fe29399081 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 27 Aug 2026 17:18:16 +0200 Subject: [PATCH] feat(quick-pane): polish compact chat controls --- docs/quick-pane-v1-design.md | 2 +- docs/quick-pane-v1-test-plan.md | 4 +- test/quick-pane-ui.test.js | 21 ++++-- ui/src-tauri/src/lib.rs | 8 +-- ui/src-tauri/tauri.conf.json | 4 +- ui/src/model-catalog.test.ts | 34 +++++++++ ui/src/model-catalog.ts | 29 +++++++- ui/src/quick-pane/QuickPane.test.tsx | 93 +++++++++++++++++++++++- ui/src/quick-pane/QuickPane.tsx | 104 ++++++++++++++++++++++----- ui/src/quick-pane/quick-pane.css | 51 ++++++++++++- ui/src/workspace/types.ts | 7 +- 11 files changed, 317 insertions(+), 40 deletions(-) create mode 100644 ui/src/model-catalog.test.ts diff --git a/docs/quick-pane-v1-design.md b/docs/quick-pane-v1-design.md index 51fc780..4a55f18 100644 --- a/docs/quick-pane-v1-design.md +++ b/docs/quick-pane-v1-design.md @@ -40,7 +40,7 @@ The app creates or focuses a dedicated Quick Pane window. `Esc` and **Close** hi `Ctrl+N` starts a new chat: it aborts active work when needed, closes the old runtime with retryable cleanup, clears draft/transcript and submitted-prompt mappings, then prepares a fresh runtime and model catalog. Automatic stale renewal performs the same idle-chat replacement while preserving the current draft. -Quick Pane opens at 640×360 with a 520×360 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped. +Quick Pane opens at 640×240 with a 520×240 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped. ## Escalation diff --git a/docs/quick-pane-v1-test-plan.md b/docs/quick-pane-v1-test-plan.md index a28731a..a208812 100644 --- a/docs/quick-pane-v1-test-plan.md +++ b/docs/quick-pane-v1-test-plan.md @@ -26,7 +26,7 @@ - `Esc` and **Close** hide without cleanup. Reopening an idle chat at or under three minutes preserves runtime, draft/catalog, and transcript; reopening after more than three minutes renews it. - `Ctrl+N` prevents browser default, aborts active work when needed, retries queued cleanup, clears draft/transcript/raw-prompt mappings, and prepares a fresh runtime/catalog. Idle reset does not send an abort. - Stale runtimes created by superseded preparation are queued after cleanup failure and retried before later stale-chat renewal. -- Quick Pane opens at 640×360 with a 520×360 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that. +- Quick Pane opens at 640×240 with a 520×240 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that. - Settings-startup and model-catalog errors show **Retry**. Catalog retry cleans up failed runtime before replacement preparation; model selection and Send stay disabled until ready. - **Continue in full session** appears after first completed assistant answer with valid handoff settings, plus preserved request/runtime escalation notices. It never starts automatically. Handoff creates normal session with transcript-derived current-chat context, workspace, and follow-up model. Current answer extraction starts only after exact mapped submitted prompt in snapshot transcript; failed follow-ups with no current assistant message never reuse prior answer as partial findings. @@ -41,7 +41,7 @@ 7. Continue; verify normal session receives handoff and follow-up model. 8. Close Quick Pane and reopen within three minutes; verify idle runtime, draft/catalog, and full transcript remain. Repeat after more than three idle minutes; verify fresh chat/runtime appears. 9. Press `Ctrl+N` while idle and active; verify fresh empty chat in both cases and active work aborts before replacement. -10. Verify pane opens at 640×360, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally. +10. Verify pane opens at 640×240, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally. 11. Induce settings-startup or model-catalog failure; verify visible **Retry**, disabled model/Send controls until recovery, and successful retry. Verify empty catalog stays blocked without unsupported actions. ## Exit criteria diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index adc88b6..07f1819 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -11,7 +11,7 @@ test("Quick Pane polls snapshots, renders answers, and gates manual transcript h assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/); assert.match(source, //); assert.match(source, /className="quick-pane-notice escalation"/); - assert.match(source, /className="quick-pane-notice continue"/); + assert.match(source, /className="quick-pane-assistant-footer"/); assert.match(source, /Continue in full session/); assert.match(source, /Quick Pane transcript:/); assert.doesNotMatch(source, /maximumSeconds|Quick time budget/); @@ -212,9 +212,9 @@ test("Quick Pane uses native dimensions and request-first compact layout", async }, { width: 640, - height: 360, + height: 240, minWidth: 520, - minHeight: 360, + minHeight: 240, transparent: true, }, ); @@ -227,13 +227,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async ); assert.match(nativeSource, /get_or_create_quick_pane/); assert.match(nativeSource, /fn resize_quick_pane/); - assert.match(nativeSource, /height\.clamp\(360\.0, 520\.0\)/); + assert.match(nativeSource, /height\.clamp\(240\.0, 520\.0\)/); 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, /startDragging\(\)/); assert.match(source, /className="quick-pane-request"/); assert.match(source, /className="quick-pane-exchange"/); assert.match(source, /className="quick-pane-composer"/); + assert.match(source, /className="quick-pane-composer-actions"/); + assert.match(source, /replaceChat\(false, true\)/); + assert.match(source, /replaceChat\(false, false\)/); assert.match(source, /className="quick-pane-send"/); assert.match(source, /ref=\{inputRef\}/); assert.match(source, /focusInput\(\)/); @@ -245,12 +250,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/); assert.match( stylesheet, - /\.quick-pane-exchange[\s\S]*?flex: 1 1 auto[\s\S]*?overflow-y: auto/, + /\.quick-pane-exchange[\s\S]*?flex: 1 1 auto[\s\S]*?overflow: hidden/, + ); + assert.match( + stylesheet, + /\.quick-pane-transcript[\s\S]*?flex: 1 1 auto[\s\S]*?overflow-y: auto/, ); assert.match( 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-composer-actions[\s\S]*?position: absolute/); assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/); assert.match(stylesheet, /@media \(max-width: 400px\)/); assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/); diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index a1510d1..27c0f6b 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -284,7 +284,7 @@ async fn get_model_catalog() -> Result { } fn quick_pane_height(height: f64) -> f64 { - height.clamp(360.0, 520.0) + height.clamp(240.0, 520.0) } #[tauri::command] @@ -790,9 +790,9 @@ mod tests { .unwrap(); assert_eq!(quick_pane.width, 640.0); - assert_eq!(quick_pane.height, 360.0); + assert_eq!(quick_pane.height, 240.0); assert_eq!(quick_pane.min_width, Some(520.0)); - assert_eq!(quick_pane.min_height, Some(360.0)); + assert_eq!(quick_pane.min_height, Some(240.0)); assert_eq!(quick_pane.url.to_string(), "index.html"); assert!(!quick_pane.visible); assert!(quick_pane.transparent); @@ -802,7 +802,7 @@ mod tests { #[test] fn quick_pane_height_stays_within_content_limit() { - assert_eq!(quick_pane_height(200.0), 360.0); + assert_eq!(quick_pane_height(200.0), 240.0); assert_eq!(quick_pane_height(420.0), 420.0); assert_eq!(quick_pane_height(900.0), 520.0); } diff --git a/ui/src-tauri/tauri.conf.json b/ui/src-tauri/tauri.conf.json index 2acb63b..3e1dd65 100644 --- a/ui/src-tauri/tauri.conf.json +++ b/ui/src-tauri/tauri.conf.json @@ -29,9 +29,9 @@ "label": "quick-pane", "title": "Pi Quick Pane", "width": 640, - "height": 360, + "height": 240, "minWidth": 520, - "minHeight": 360, + "minHeight": 240, "center": true, "visible": false, "decorations": false, diff --git a/ui/src/model-catalog.test.ts b/ui/src/model-catalog.test.ts new file mode 100644 index 0000000..b9f3414 --- /dev/null +++ b/ui/src/model-catalog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + catalogModels, + reconcileThinkingLevel, + thinkingLevelOptions, +} from "./model-catalog"; + +describe("thinking model catalog metadata", () => { + it("uses catalog thinking levels and reconciles removed selections", () => { + const model = { + provider: "provider", + id: "reasoning", + thinkingLevelMap: { off: "off", low: "low", high: "high" }, + }; + expect(thinkingLevelOptions(model)).toEqual([ + { value: "off", label: "Off" }, + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ]); + expect(reconcileThinkingLevel(model, "high")).toBe("high"); + expect(reconcileThinkingLevel(model, "medium")).toBe("off"); + }); + + it("keeps unknown and dynamically configured local models at Off", () => { + expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]); + const [dynamic] = catalogModels([], [ + { provider: "localai", modelId: "local/unknown" }, + ]); + expect(dynamic.thinkingLevelMap).toEqual({ off: "off" }); + expect(thinkingLevelOptions(dynamic)).toEqual([ + { value: "off", label: "Off" }, + ]); + }); +}); diff --git a/ui/src/model-catalog.ts b/ui/src/model-catalog.ts index 543b239..73849a6 100644 --- a/ui/src/model-catalog.ts +++ b/ui/src/model-catalog.ts @@ -1,6 +1,9 @@ import type { Model } from "./workspace/types"; export type ModelIdentity = { provider: string; modelId: string }; +export type ThinkingLevelOption = { value: string; label: string }; + +const OFF_THINKING_LEVEL: ThinkingLevelOption = { value: "off", label: "Off" }; export function modelIdentity(model: Model): ModelIdentity | undefined { if (!model.provider || !model.id) return undefined; @@ -45,6 +48,25 @@ export function reconcileModelIdentity( ); } +export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] { + const map = model?.thinkingLevelMap; + if (!map || typeof map !== "object") return [OFF_THINKING_LEVEL]; + const options = Object.keys(map) + .filter((level) => level.trim() && typeof map[level] === "string") + .map((level) => ({ + value: level, + label: level === "off" ? "Off" : level[0].toUpperCase() + level.slice(1), + })); + return options.length ? options : [OFF_THINKING_LEVEL]; +} + +export function reconcileThinkingLevel(model: Model | undefined, level: string) { + const options = thinkingLevelOptions(model); + return options.some((option) => option.value === level) + ? level + : options.find((option) => option.value === "off")?.value ?? options[0].value; +} + export function catalogModels( models: Model[], configured: ModelIdentity[], @@ -60,7 +82,12 @@ export function catalogModels( if (!identity.provider || !identity.modelId) continue; const key = modelIdentityValue(identity); if (keys.has(key)) continue; - available.unshift({ provider: identity.provider, id: identity.modelId }); + available.unshift({ + provider: identity.provider, + id: identity.modelId, + // Configured/local models are not catalog-confirmed. Do not imply thinking support. + thinkingLevelMap: { off: "off" }, + }); keys.add(key); } return available; diff --git a/ui/src/quick-pane/QuickPane.test.tsx b/ui/src/quick-pane/QuickPane.test.tsx index 41add71..62c935e 100644 --- a/ui/src/quick-pane/QuickPane.test.tsx +++ b/ui/src/quick-pane/QuickPane.test.tsx @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn(), onFocusChanged: vi.fn(), + startDragging: vi.fn(), focusHandler: undefined as | ((event: { payload: boolean }) => void) | undefined, @@ -28,7 +29,10 @@ const mocks = vi.hoisted(() => ({ 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: () => ({ onFocusChanged: mocks.onFocusChanged }), + getCurrentWindow: () => ({ + onFocusChanged: mocks.onFocusChanged, + startDragging: mocks.startDragging, + }), })); import QuickPane from "./QuickPane"; @@ -212,6 +216,7 @@ describe("QuickPane", () => { vi.restoreAllMocks(); vi.clearAllMocks(); mocks.focusHandler = undefined; + mocks.startDragging.mockResolvedValue(undefined); mocks.listen.mockResolvedValue(vi.fn()); mocks.onFocusChanged.mockImplementation((handler) => { mocks.focusHandler = handler; @@ -227,12 +232,95 @@ describe("QuickPane", () => { expect( Array.from(pane?.children ?? []).map((element) => element.className), ).toEqual([ + "quick-pane-drag-strip", "quick-pane-toolbar", "quick-pane-composer", "quick-pane-exchange", ]); }); + it("starts native dragging only from the strip above toolbar controls", async () => { + bridgeMock([defaultModels]); + const { container } = render(); + await preparedTrigger(); + fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!); + expect(mocks.startDragging).toHaveBeenCalledTimes(1); + fireEvent.pointerDown(screen.getByRole("button", { name: "Close Quick Pane" })); + expect(mocks.startDragging).toHaveBeenCalledTimes(1); + }); + + it("derives thinking choices from selected model metadata and falls back to Off", async () => { + const thinkingModels: Model[] = [ + { + provider: "provider", + id: "quick/model", + name: "Quick Model", + thinkingLevelMap: { off: "off", high: "high" }, + }, + ]; + bridgeMock([thinkingModels], () => runtimeSnapshot(thinkingModels, "", true)); + render(); + await preparedTrigger(); + const thinking = screen.getByRole("combobox", { name: "Thinking" }); + expect(within(thinking).getAllByRole("option").map((option) => option.textContent)).toEqual([ + "Off", + "High", + ]); + await userEvent.selectOptions(thinking, "high"); + await ask("Inspect"); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { + agentId: "agent-1", + level: "high", + }), + ); + }); + + it("keeps Abort active-only and Renew idle after an exchange", async () => { + let streaming = true; + const tracker = bridgeMock([defaultModels, defaultModels], () => + runtimeSnapshot(defaultModels, "Partial answer", streaming), + ); + render(); + await preparedTrigger(); + expect(screen.queryByRole("button", { name: "Abort" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Renew" })).toBeNull(); + await ask("Inspect"); + expect(await screen.findByRole("button", { name: "Abort" })).toBeEnabled(); + await userEvent.click(screen.getByRole("button", { name: "Abort" })); + await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); + expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send request" })).toBeEnabled(), + ); + + streaming = false; + await ask("Again"); + expect(await screen.findByRole("button", { name: "Renew" })).toBeEnabled(); + }); + + it("reports Abort failures without replacing active chat", async () => { + bridgeMock([defaultModels], () => + runtimeSnapshot(defaultModels, "Partial answer", true), + ); + const baseImplementation = mocks.invoke.getMockImplementation(); + mocks.invoke.mockImplementation((command: string, payload?: Record) => { + if (command === "abort") return Promise.reject(new Error("abort unavailable")); + return baseImplementation?.(command, payload); + }); + render(); + await preparedTrigger(); + await ask("Inspect"); + await userEvent.click(await screen.findByRole("button", { name: "Abort" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not abort quick request: Error: abort unavailable", + ); + expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }); + expect(screen.getByRole("button", { name: "Abort" })).toBeEnabled(); + }); + it("keeps downward model menu inside the scroll-contained pane", async () => { const user = userEvent.setup(); bridgeMock([defaultModels]); @@ -371,6 +459,7 @@ describe("QuickPane", () => { const continueButton = await screen.findByRole("button", { name: "Continue in full session", }); + expect(continueButton.closest(".quick-pane-assistant-footer")).not.toBeNull(); expect(mocks.invoke).not.toHaveBeenCalledWith( "create_session_runtime", expect.anything(), @@ -448,7 +537,7 @@ describe("QuickPane", () => { "YouFirst request", "AssistantFirst answer", "YouSecond request", - "AssistantSecond answer", + "AssistantSecond answerContinue in full session", ]); }); diff --git a/ui/src/quick-pane/QuickPane.tsx b/ui/src/quick-pane/QuickPane.tsx index 4741549..da8e471 100644 --- a/ui/src/quick-pane/QuickPane.tsx +++ b/ui/src/quick-pane/QuickPane.tsx @@ -10,6 +10,8 @@ import { modelIdentity, modelIdentityValue, reconcileModelIdentity, + reconcileThinkingLevel, + thinkingLevelOptions, type ModelIdentity, } from "../model-catalog"; import "./quick-pane.css"; @@ -176,6 +178,7 @@ export default function QuickPane() { const [request, setRequest] = useState(""); const [submissionStarting, setSubmissionStarting] = useState(false); const [requestActive, setRequestActive] = useState(false); + const [chatReplacing, setChatReplacing] = useState(false); const [thinkingLevel, setThinkingLevel] = useState("medium"); const [models, setModels] = useState([]); const [catalogStatus, setCatalogStatus] = useState< @@ -230,6 +233,19 @@ export default function QuickPane() { const resizeFrameRef = useRef(undefined); const requestedPaneHeightRef = useRef(undefined); const invalid = useMemo(() => settingsError(settings), [settings]); + const selectedCatalogModel = useMemo( + () => + models.find( + (model) => + model.provider === selectedModel.provider && + model.id === selectedModel.modelId, + ), + [models, selectedModel], + ); + const thinkingOptions = useMemo( + () => thinkingLevelOptions(selectedCatalogModel), + [selectedCatalogModel], + ); const runActive = submissionStarting || requestActive; const activityPhase = submissionStarting ? "Starting" @@ -245,6 +261,12 @@ export default function QuickPane() { ? `${activityPhase} · ${elapsedSeconds}s` : activityPhase; + useEffect(() => { + setThinkingLevel((current) => + reconcileThinkingLevel(selectedCatalogModel, current), + ); + }, [selectedCatalogModel]); + const focusInput = () => inputRef.current?.focus(); const closeRuntime = useCallback(async (runtimeId: string) => { const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId); @@ -448,6 +470,7 @@ export default function QuickPane() { async (clearDraft: boolean, abortActive: boolean) => { if (resetStartingRef.current) return false; resetStartingRef.current = true; + setChatReplacing(true); preparationGenerationRef.current += 1; preparationLockRef.current = false; catalogReadyRef.current = false; @@ -456,9 +479,12 @@ export default function QuickPane() { requestActiveRef.current || submissionStartingRef.current; try { if (abortActive && wasActive && current) { - await invoke("abort", { agentId: current.agentId }).catch( - () => undefined, - ); + try { + await invoke("abort", { agentId: current.agentId }); + } catch (reason) { + setError(`Could not abort quick request: ${String(reason)}`); + return false; + } } clearChatState(clearDraft); if (current) { @@ -472,6 +498,7 @@ export default function QuickPane() { return await prepareRuntime(selectedModelRef.current, true); } finally { resetStartingRef.current = false; + setChatReplacing(false); window.requestAnimationFrame(focusInput); } }, @@ -534,7 +561,7 @@ export default function QuickPane() { const height = Math.min( 520, Math.max( - 360, + 240, Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight), ), ); @@ -801,6 +828,13 @@ export default function QuickPane() { const canContinue = !invalid && !!handoffRequest; const completedAssistantAnswer = responseComplete && messages.some((message) => message.role === "assistant"); + const completedAssistantMessageId = completedAssistantAnswer + ? [...messages].reverse().find((message) => message.role === "assistant")?.id + : undefined; + const hasExchange = + messages.length > 0 || + responseComplete || + submittedRequestRef.current !== undefined; const transcriptHandoff = messages .map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`, @@ -834,6 +868,13 @@ export default function QuickPane() { return (
+