import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { AlertCircle, ArrowUp, Bot, ChevronDown, Circle, GitBranch, Loader2, Sparkles, User } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { ProviderIcon } from '@renderer/components/ProviderIcons'; import { findModel, getSupportedReasoningEfforts, inferProvider, providerMeta, resolveReasoningEffort, type ModelDefinition, } from '@shared/domain/models'; import { reasoningEffortOptions, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; function ThinkingDots() { return (
); } /* ── Tier badge for model dropdown ─────────────────────────── */ function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) { if (!tier) return null; 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, models, onChange, disabled, }: { value: string; models: ReadonlyArray; 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, models); const provider = selected?.provider ?? inferProvider(value); const displayName = selected?.name ?? value ?? 'Model'; const groupedModels = providerMeta .map((pg) => ({ ...pg, models: models.filter((m) => m.provider === pg.id) })) .filter((pg) => pg.models.length > 0); const otherModels = models.filter((m) => !m.provider); return (
{open && !disabled && (
{groupedModels.map((pg) => (
{pg.label}
{pg.models.map((model) => ( ))}
))} {otherModels.length > 0 && (
Other
{otherModels.map((model) => ( ))}
)}
)}
); } /* ── Inline thinking effort pill with dropdown ─────────────── */ function InlineThinkingPill({ value, supportedEfforts, onChange, disabled, }: { value?: ReasoningEffort; supportedEfforts?: ReadonlyArray; 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 options = supportedEfforts ? reasoningEffortOptions.filter((o) => supportedEfforts.includes(o.value)) : [...reasoningEffortOptions]; if (supportedEfforts && supportedEfforts.length === 0) { return ( N/A ); } const currentLabel = options.find((o) => o.value === value)?.label ?? value ?? 'Thinking'; return (
{open && !disabled && (
{options.map((option) => ( ))}
)}
); } /* ── ChatPane ──────────────────────────────────────────────── */ interface ChatPaneProps { project: ProjectRecord; pattern: PatternDefinition; session: SessionRecord; availableModels: ReadonlyArray; onSend: (content: string) => Promise; onUpdateScratchpadConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; }) => Promise; } export function ChatPane({ project, pattern, session, availableModels, onSend, onUpdateScratchpadConfig, }: ChatPaneProps) { const [input, setInput] = useState(''); const [configError, setConfigError] = useState(); const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false); const transcriptRef = useRef(null); const textareaRef = useRef(null); const isSessionBusy = session.status === 'running'; const isScratchpad = isScratchpadProject(project); const primaryAgent = pattern.agents[0]; const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined; const supportedEfforts = getSupportedReasoningEfforts(selectedModel); const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig; useEffect(() => { transcriptRef.current?.scrollTo({ top: transcriptRef.current.scrollHeight, behavior: 'smooth', }); }, [session.messages.length, isSessionBusy]); useEffect(() => { setConfigError(undefined); setIsUpdatingScratchpadConfig(false); }, [session.id]); async function handleSubmit() { const text = input.trim(); if (!text || isComposerDisabled) return; setInput(''); await onSend(text); } async function handleScratchpadConfigChange(config: { model: string; reasoningEffort?: ReasoningEffort; }) { if (!isScratchpad || !primaryAgent || isComposerDisabled || !onUpdateScratchpadConfig) { return; } if ( config.model === primaryAgent.model && config.reasoningEffort === scratchpadReasoningEffort ) { return; } setConfigError(undefined); setIsUpdatingScratchpadConfig(true); try { await onUpdateScratchpadConfig(config); } catch (error) { setConfigError(error instanceof Error ? error.message : String(error)); } finally { setIsUpdatingScratchpadConfig(false); } } function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void handleSubmit(); } } return (
{/* Header — extra top padding clears the title bar overlay zone */}

{session.title}

{isScratchpad ? `Scratchpad · ${pattern.name}` : `${project.name} · ${pattern.name} · ${pattern.mode}`} {!isScratchpad && project.git?.status === 'ready' && ( {project.git.branch ?? project.git.head?.shortHash ?? 'HEAD'} {project.git.isDirty && ( )} {(project.git.ahead ?? 0) > 0 && ↑{project.git.ahead}} {(project.git.behind ?? 0) > 0 && ↓{project.git.behind}} )}

{isSessionBusy && } {session.status === 'error' && (
Error
)} {session.status === 'idle' && session.messages.length > 0 && ( {session.messages.length} message{session.messages.length === 1 ? '' : 's'} )}
{/* Messages */}
{session.messages.length === 0 ? (

Send a message to start the conversation

{isScratchpad ? ( <> Scratchpad is ready for ad-hoc questions using{' '} {pattern.name} ) : ( <> Using {pattern.name} in{' '} {project.name} )}

) : (
{session.messages.map((message, index) => { const isUser = message.role === 'user'; const phase = getAssistantMessagePhase(session, message, index); const assistantContainerClass = phase === 'thinking' ? 'border-sky-500/20 bg-sky-500/5' : phase === 'final' ? 'border-emerald-500/20 bg-emerald-500/5' : 'border-zinc-800 bg-zinc-900/40'; const assistantBadgeClass = phase === 'thinking' ? 'border-sky-400/20 bg-sky-400/10 text-sky-300' : 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300'; const phaseLabel = phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; return (
{isUser ? : }
{message.authorName} {!isUser && phaseLabel && ( {phaseLabel} )}
{!isUser && message.pending ? (
{message.content}
) : ( )} {message.pending && message.content && ( )}
{message.pending && !message.content && }
); })}
)}
{/* Input area */}
{session.lastError && (
{session.lastError}
)} {configError && (
{configError}
)}
{/* Scratchpad config pills — inline above composer */} {isScratchpad && primaryAgent && (
{ const nextModel = findModel(modelId, availableModels); void handleScratchpadConfigChange({ model: modelId, reasoningEffort: resolveReasoningEffort(nextModel, scratchpadReasoningEffort), }); }} value={primaryAgent.model} /> void handleScratchpadConfigChange({ model: primaryAgent.model, reasoningEffort, }) } supportedEfforts={supportedEfforts} value={scratchpadReasoningEffort} /> {isUpdatingScratchpadConfig && ( )}
)}