From 4e4f6ebedb78f27774ed57a5d4fff71aec059665 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 22 Mar 2026 10:21:44 +0100 Subject: [PATCH] feat: redesign scratchpad config as compact inline pills Replace the large bordered card with model/thinking dropdowns with subtle inline pill-buttons that sit just above the composer textarea. - Create InlineModelPill: compact pill showing provider icon + model name with upward-opening grouped dropdown (same model catalog) - Create InlineThinkingPill: compact pill with Sparkles icon and effort label with upward-opening dropdown - Both pills use minimal border styling that highlights on open/hover - Dropdowns open upward (bottom-full) so they don't get clipped - Add loading spinner next to pills while config is saving - Remove the large 'Applies to future replies' card wrapper Also fixes 'onUpdateScratchpadConfig is not a function' error: - Make onUpdateScratchpadConfig optional in ChatPaneProps (only relevant for scratchpad sessions, not regular project sessions) - Guard the call with !onUpdateScratchpadConfig early return Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/ChatPane.tsx | 223 ++++++++++++++++++++++----- 1 file changed, 188 insertions(+), 35 deletions(-) diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index b0acb9a..3fcb19f 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,11 +1,13 @@ import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react'; +import { AlertCircle, ArrowUp, Bot, ChevronDown, Loader2, Sparkles, User } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; -import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields'; +import { ProviderIcon } from '@renderer/components/ProviderIcons'; +import { findModel, inferProvider, modelCatalog, providerMeta, type ModelDefinition } from '@shared/domain/models'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; +import { reasoningEffortOptions } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; @@ -19,12 +21,169 @@ function ThinkingDots() { ); } +/* ── Tier badge for model dropdown ─────────────────────────── */ + +function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) { + const styles = { + premium: 'bg-amber-500/10 text-amber-400', + standard: 'bg-zinc-700/50 text-zinc-500', + fast: 'bg-emerald-500/10 text-emerald-400', + }; + return ( + + {tier} + + ); +} + +/* ── Inline model pill with dropdown ───────────────────────── */ + +function InlineModelPill({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (model: string) => void; + disabled: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + const selected = findModel(value); + const provider = selected?.provider ?? inferProvider(value); + const displayName = selected?.name ?? value ?? 'Model'; + + return ( +
+ + + {open && !disabled && ( +
+ {providerMeta.map((pg) => { + const models = modelCatalog.filter((m) => m.provider === pg.id); + return ( +
+
+ + + {pg.label} + +
+ {models.map((model) => ( + + ))} +
+ ); + })} +
+ )} +
+ ); +} + +/* ── Inline thinking effort pill with dropdown ─────────────── */ + +function InlineThinkingPill({ + value, + onChange, + disabled, +}: { + value: ReasoningEffort; + onChange: (effort: ReasoningEffort) => void; + disabled: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + const currentLabel = reasoningEffortOptions.find((o) => o.value === value)?.label ?? value; + + return ( +
+ + + {open && !disabled && ( +
+ {reasoningEffortOptions.map((option) => ( + + ))} +
+ )} +
+ ); +} + +/* ── ChatPane ──────────────────────────────────────────────── */ + interface ChatPaneProps { project: ProjectRecord; pattern: PatternDefinition; session: SessionRecord; onSend: (content: string) => Promise; - onUpdateScratchpadConfig: (config: { + onUpdateScratchpadConfig?: (config: { model: string; reasoningEffort: ReasoningEffort; }) => Promise; @@ -72,7 +231,7 @@ export function ChatPane({ model: string; reasoningEffort: ReasoningEffort; }) { - if (!isScratchpad || !primaryAgent || isComposerDisabled) { + if (!isScratchpad || !primaryAgent || isComposerDisabled || !onUpdateScratchpadConfig) { return; } @@ -231,38 +390,32 @@ export function ChatPane({ )}
+ {/* Scratchpad config pills — inline above composer */} {isScratchpad && primaryAgent && ( -
-
-
- - void handleScratchpadConfigChange({ - model, - reasoningEffort: scratchpadReasoningEffort, - }) - } - value={primaryAgent.model} - /> -
-
- - void handleScratchpadConfigChange({ - model: primaryAgent.model, - reasoningEffort, - }) - } - value={scratchpadReasoningEffort} - /> -
-
-

- Applies to future replies in this scratchpad. -

+
+ + void handleScratchpadConfigChange({ + model, + reasoningEffort: scratchpadReasoningEffort, + }) + } + value={primaryAgent.model} + /> + + void handleScratchpadConfigChange({ + model: primaryAgent.model, + reasoningEffort, + }) + } + value={scratchpadReasoningEffort} + /> + {isUpdatingScratchpadConfig && ( + + )}
)}