import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, ArrowUp, Bookmark, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner'; import { MessageActions } from '@renderer/components/chat/MessageActions'; import { MessageEditComposer } from '@renderer/components/chat/MessageEditComposer'; import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner'; import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner'; import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess'; import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { ApprovalDecision } from '@shared/domain/approval'; import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment'; import type { SessionUsageState } from '@renderer/lib/sessionActivity'; import type { ActiveSubagent } from '@renderer/lib/subagentTracker'; import { findModel, getSupportedReasoningEfforts, resolveReasoningEffort, type ModelDefinition, } from '@shared/domain/models'; import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session'; import { countApprovedToolsInGroups, groupApprovalToolsByProvider, listApprovalToolDefinitions, type RuntimeToolDefinition, type SessionToolingSelection, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; /* ── ChatPane ──────────────────────────────────────────────── */ interface ChatPaneProps { project: ProjectRecord; pattern: PatternDefinition; session: SessionRecord; availableModels: ReadonlyArray; toolingSettings: WorkspaceToolingSettings; mcpProbingServerIds?: string[]; runtimeTools?: ReadonlyArray; sessionUsage?: SessionUsageState; activeSubagents?: ReadonlyArray; terminalOpen?: boolean; terminalRunning?: boolean; onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise; onCancelTurn?: () => void; onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise; onSetInteractionMode?: (mode: InteractionMode) => void; onDismissPlanReview?: () => void; onDismissMcpAuth?: () => void; onAuthenticateMcp?: () => void; onTerminalToggle?: () => void; onUpdateSessionModelConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; }) => Promise; onUpdateSessionTooling?: (selection: SessionToolingSelection) => void; onUpdateSessionApprovalSettings?: (settings: { autoApprovedToolNames?: string[] }) => void; onBranchFromMessage?: (messageId: string) => void; onPinMessage?: (messageId: string, isPinned: boolean) => void; onRegenerateMessage?: (messageId: string) => void; onEditAndResendMessage?: (messageId: string, content: string) => void; branchOriginLabel?: string; } export function ChatPane({ project, pattern, session, availableModels, toolingSettings, mcpProbingServerIds, runtimeTools, sessionUsage, activeSubagents, terminalOpen, terminalRunning, onSend, onCancelTurn, onResolveApproval, onResolveUserInput, onSetInteractionMode, onDismissPlanReview, onDismissMcpAuth, onAuthenticateMcp, onTerminalToggle, onUpdateSessionModelConfig, onUpdateSessionTooling, onUpdateSessionApprovalSettings, onBranchFromMessage, onPinMessage, onRegenerateMessage, onEditAndResendMessage, branchOriginLabel, }: ChatPaneProps) { const [hasComposerContent, setHasComposerContent] = useState(false); const [configError, setConfigError] = useState(); const [approvalError, setApprovalError] = useState(); const [isResolvingApproval, setIsResolvingApproval] = useState(false); const [isSubmittingUserInput, setIsSubmittingUserInput] = useState(false); const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false); const [editingMessageId, setEditingMessageId] = useState(); const transcriptRef = useRef(null); const composerRef = useRef(null); const isSessionBusy = session.status === 'running'; const { visibleMessages, thinkingMessages } = useMemo(() => { const visible: typeof session.messages = []; const thinking: typeof session.messages = []; for (const message of session.messages) { if (message.messageKind === 'thinking') { thinking.push(message); } else { visible.push(message); } } return { visibleMessages: visible, thinkingMessages: thinking }; }, [session.messages]); const lastAssistantIndex = useMemo(() => { for (let i = visibleMessages.length - 1; i >= 0; i--) { if (visibleMessages[i].role === 'assistant') return i; } return -1; }, [visibleMessages]); const turnStartedAt = useMemo(() => { if (session.runs.length === 0) return undefined; return session.runs[0].startedAt; }, [session.runs]); const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined; const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const pendingUserInput = session.pendingUserInput?.status === 'pending' ? session.pendingUserInput : undefined; const pendingPlanReview = session.pendingPlanReview?.status === 'pending' ? session.pendingPlanReview : undefined; const pendingMcpAuth = session.pendingMcpAuth?.status === 'pending' || session.pendingMcpAuth?.status === 'authenticating' || session.pendingMcpAuth?.status === 'failed' ? session.pendingMcpAuth : undefined; const interactionMode: InteractionMode = session.interactionMode ?? 'interactive'; const isPlanMode = interactionMode === 'plan'; const isScratchpad = isScratchpadProject(project); const isSingleAgent = pattern.agents.length === 1; const primaryAgent = pattern.agents[0]; const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined; const supportedEfforts = getSupportedReasoningEfforts(selectedModel); const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); const isComposerDisabled = isUpdatingSessionModelConfig; const canSubmitInput = hasComposerContent && !isComposerDisabled; const [pendingAttachments, setPendingAttachments] = useState([]); const promptFiles = useMemo(() => project.customization?.promptFiles ?? [], [project.customization?.promptFiles]); const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); const mcpServers = toolingSettings.mcpServers; const lspProfiles = toolingSettings.lspProfiles; const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0; const hasToolCallApproval = pattern.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false; const approvalTools = useMemo( () => listApprovalToolDefinitions(toolingSettings, runtimeTools), [runtimeTools, toolingSettings], ); const isApprovalOverridden = session.approvalSettings !== undefined; const effectiveAutoApproved = useMemo( () => new Set( isApprovalOverridden ? session.approvalSettings!.autoApprovedToolNames : pattern.approvalPolicy?.autoApprovedToolNames ?? [], ), [isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy], ); const effectiveAutoApprovedCount = useMemo(() => { const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings); return countApprovedToolsInGroups(groups, effectiveAutoApproved); }, [approvalTools, effectiveAutoApproved, toolingSettings]); const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0; const hasApprovalContent = approvalTools.length > 0 || isProbingMcp; useEffect(() => { transcriptRef.current?.scrollTo({ top: transcriptRef.current.scrollHeight, behavior: 'smooth', }); }, [session.messages.length, isSessionBusy]); useEffect(() => { setConfigError(undefined); setApprovalError(undefined); setIsResolvingApproval(false); setIsUpdatingSessionModelConfig(false); setEditingMessageId(undefined); }, [session.id]); function handleComposerSubmit(content: string) { const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined; const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined; setPendingAttachments([]); void onSend(content, attachments, messageMode); } const handleCopyMessage = useCallback((content: string) => { void navigator.clipboard.writeText(content); }, []); const handleEditSave = useCallback( (messageId: string, content: string) => { setEditingMessageId(undefined); onEditAndResendMessage?.(messageId, content); }, [onEditAndResendMessage], ); function handleDismissPlan() { onDismissPlanReview?.(); } function handleDismissMcpAuth() { onDismissMcpAuth?.(); } function handleAuthenticateMcp() { onAuthenticateMcp?.(); } async function handleSessionModelConfigChange(config: { model: string; reasoningEffort?: ReasoningEffort; }) { if (!isSingleAgent || !primaryAgent || isComposerDisabled || !onUpdateSessionModelConfig) { return; } if ( config.model === primaryAgent.model && config.reasoningEffort === sessionReasoningEffort ) { return; } setConfigError(undefined); setIsUpdatingSessionModelConfig(true); try { await onUpdateSessionModelConfig(config); } catch (error) { setConfigError(error instanceof Error ? error.message : String(error)); } finally { setIsUpdatingSessionModelConfig(false); } } async function handleResolveApproval(decision: ApprovalDecision, alwaysApprove?: boolean) { if (!pendingApproval || !onResolveApproval || isResolvingApproval) return; setApprovalError(undefined); setIsResolvingApproval(true); try { await onResolveApproval(pendingApproval.id, decision, alwaysApprove); } catch (error) { setApprovalError(error instanceof Error ? error.message : String(error)); } finally { setIsResolvingApproval(false); } } async function handleResolveUserInput(answer: string, wasFreeform: boolean) { if (!pendingUserInput || !onResolveUserInput || isSubmittingUserInput) return; setIsSubmittingUserInput(true); try { await onResolveUserInput(pendingUserInput.id, answer, wasFreeform); } catch { // User input errors are non-critical; the turn will fail and show the error status } finally { setIsSubmittingUserInput(false); } } 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}} )}

{pendingApproval && (
Awaiting approval {queuedApprovals.length > 0 && ( +{queuedApprovals.length} queued )}
)} {pendingUserInput && !pendingApproval && (
Awaiting your input
)} {isSessionBusy && !pendingApproval && !pendingUserInput && } {session.status === 'error' && (
Error
)} {session.status === 'idle' && !pendingApproval && !pendingUserInput && 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} )}

) : (
{/* Branch origin banner */} {session.branchOrigin && ( )}
{visibleMessages.map((message, index) => { const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; const isLastAssistant = index === lastAssistantIndex; const phase = getAssistantMessagePhase(session, message, index); const assistantContainerClass = phase === 'thinking' ? 'border-[var(--color-accent-sky)]/20 bg-[var(--color-accent-sky)]/5' : phase === 'final' ? 'border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5' : 'border-[var(--color-border)] bg-[var(--color-surface-1)]/40'; const assistantBadgeClass = phase === 'thinking' ? 'border-[var(--color-accent-sky)]/20 bg-[var(--color-accent-sky)]/10 text-[var(--color-accent-sky)]' : 'border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]'; const phaseLabel = phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; const showActions = !isSessionBusy && !message.pending; const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0; return (
{showThinkingBefore && (
)}
{isUser ? : }
{message.authorName} {message.isPinned && ( )} {!isUser && phaseLabel && ( {phaseLabel} )} {showActions && (
handleCopyMessage(message.content)} onPin={() => onPinMessage?.(message.id, !message.isPinned)} onBranch={() => onBranchFromMessage?.(message.id)} onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined} onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined} />
)}
{/* Edit mode */} {isEditing ? ( handleEditSave(message.id, content)} onCancel={() => setEditingMessageId(undefined)} /> ) : (
{/* Attachment thumbnails */} {isUser && message.attachments && message.attachments.length > 0 && (
{message.attachments.map((att, attIdx) => isImageAttachment(att) ? ( {getAttachmentDisplayName(att)} ) : (
{getAttachmentDisplayName(att)}
), )}
)} {!isUser && message.pending ? (
{message.content}
) : ( )} {message.pending && message.content && ( )}
)} {message.pending && !message.content && }
); })} {thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
)}
{activeSubagents && activeSubagents.length > 0 && (
)}
)}
{/* Input area */}
{session.lastError && (
{session.lastError}
)} {configError && (
{configError}
)} {approvalError && (
{approvalError}
)}
{/* Pending approval banner */} {pendingApproval && (
void handleResolveApproval(decision, alwaysApprove)} position={totalPendingCount > 1 ? 1 : undefined} total={totalPendingCount > 1 ? totalPendingCount : undefined} /> {queuedApprovals.length > 0 && ( )}
)} {/* Pending user input banner */} {pendingUserInput && (
void handleResolveUserInput(answer, wasFreeform)} userInput={pendingUserInput} />
)} {/* Plan review banner */} {pendingPlanReview && (
)} {/* MCP auth required banner */} {pendingMcpAuth && (
)} {/* Session config pills — tools/approval left, model/reasoning right */} {isSingleAgent && (
{hasConfigurableTools && onUpdateSessionTooling && ( )} {hasToolCallApproval && onUpdateSessionApprovalSettings && hasApprovalContent && ( )} {primaryAgent && (
{ const nextModel = findModel(modelId, availableModels); void handleSessionModelConfigChange({ model: modelId, reasoningEffort: resolveReasoningEffort(nextModel, sessionReasoningEffort), }); }} value={primaryAgent.model} /> void handleSessionModelConfigChange({ model: primaryAgent.model, reasoningEffort, }) } supportedEfforts={supportedEfforts} value={sessionReasoningEffort} /> {isUpdatingSessionModelConfig && ( )}
)}
)} {/* Session config pills — tool & approval controls (multi-agent) */} {!isSingleAgent && (hasConfigurableTools || hasToolCallApproval) && (
{hasConfigurableTools && onUpdateSessionTooling && ( )} {hasToolCallApproval && onUpdateSessionApprovalSettings && hasApprovalContent && ( )}
)} {/* Attachment preview */} {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment, index) => (
{getAttachmentDisplayName(attachment)}
))}
)}
{/* Bottom action bar: left = shortcuts, right = buttons */}
{/* Left: quick actions */}
{onTerminalToggle && ( )} {!isScratchpad && promptFiles.length > 0 && ( void onSend(content)} promptFiles={promptFiles} /> )}
{/* Right: attach, plan mode, send */}
{/* Attachment picker */} {/* Plan mode toggle */} {onSetInteractionMode && !isSessionBusy && ( )} {/* Send / Stop / Steer button */}
{isPlanMode && !isSessionBusy && (
Plan mode — the agent will propose a plan instead of implementing
)} {isSessionBusy && (hasComposerContent || pendingAttachments.length > 0) && (
Steering — your message will be injected into the current turn
)}
{/* Session usage bar */} {sessionUsage && sessionUsage.tokenLimit > 0 && (
0.9 ? 'bg-[var(--color-status-error)]' : sessionUsage.currentTokens / sessionUsage.tokenLimit > 0.7 ? 'bg-[var(--color-status-warning)]' : 'bg-[var(--color-accent)]/60' }`} style={{ width: `${Math.min(100, (sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}%` }} />
{Math.round((sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}% context
)}
); } /* ── Branch origin banner ───────────────────────────────────── */ function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) { const icon = action === 'regenerate' ? : ; const verb = action === 'regenerate' ? 'Regenerated from' : action === 'edit-and-resend' ? 'Edited & resent from' : 'Branched from'; return (
{icon} {verb}{' '} {label ?? 'a previous session'}
); }