import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertCircle, ArrowUp, Bookmark, Bot, ChevronDown, ChevronRight, Circle, ClipboardList, FileText, 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, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlinePromptPill, type ArmedPrompt } from '@renderer/components/chat/InlinePromptPill'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { TurnActivityPanel } from '@renderer/components/chat/TurnActivityPanel'; 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, SessionActivityState } from '@renderer/lib/sessionActivity'; import { summarizeSessionActivity } from '@renderer/lib/sessionActivity'; import type { ActiveSubagent } from '@renderer/lib/subagentTracker'; import { findModel, getSupportedReasoningEfforts, resolveReasoningEffort, type ModelDefinition, } from '@shared/domain/models'; import { resolveWorkflowAgentNodes, type AgentNodeConfig, type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow'; import { isScratchpadProject, type ProjectGitFileReference, type ProjectRecord } from '@shared/domain/project'; import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session'; import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; import { countApprovedToolsInGroups, groupApprovalToolsByProvider, listApprovalToolDefinitions, type RuntimeToolDefinition, type SessionToolingSelection, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; /* ── ChatPane ──────────────────────────────────────────────── */ type DisplayItem = | { type: 'message'; message: ChatMessageRecord } | { type: 'turn-activity'; thinkingMessages: ChatMessageRecord[]; run?: SessionRunRecord; turnStartedAt?: string; /** Agent names in this turn group, derived from thinking message authors. Used to scope run events. */ agentNames?: ReadonlySet; /** True when this is the last turn-activity panel that shares a given run (controls git summary / discard). */ isLastRunPanel?: boolean; }; interface ChatPaneProps { project: ProjectRecord; workflow: WorkflowDefinition; session: SessionRecord; availableModels: ReadonlyArray; toolingSettings: WorkspaceToolingSettings; mcpProbingServerIds?: string[]; runtimeTools?: ReadonlyArray; sessionUsage?: SessionUsageState; sessionActivity?: SessionActivityState; activeSubagents?: ReadonlyArray; terminalOpen?: boolean; terminalRunning?: boolean; gitPanelOpen?: boolean; gitDirty?: boolean; onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode, promptInvocation?: ProjectPromptInvocation) => 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; onGitToggle?: () => 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; onDiscardRunChanges?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; onOpenCommitComposer?: () => void; branchOriginLabel?: string; } export function ChatPane({ project, workflow, session, availableModels, toolingSettings, mcpProbingServerIds, runtimeTools, sessionUsage, sessionActivity, activeSubagents, terminalOpen, terminalRunning, gitPanelOpen, gitDirty, onSend, onCancelTurn, onResolveApproval, onResolveUserInput, onSetInteractionMode, onDismissPlanReview, onDismissMcpAuth, onAuthenticateMcp, onTerminalToggle, onGitToggle, onUpdateSessionModelConfig, onUpdateSessionTooling, onUpdateSessionApprovalSettings, onBranchFromMessage, onPinMessage, onRegenerateMessage, onEditAndResendMessage, onDiscardRunChanges, onOpenCommitComposer, 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 displayItems = useMemo(() => { const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r])); const items: DisplayItem[] = []; let pendingThinking: ChatMessageRecord[] = []; let lastUserMessageId: string | undefined; const messages = session.messages; const busy = session.status === 'running'; // Track which runs have been attached to a turn-activity item so we // can detect orphaned runs that need their own panel. const consumedRunIds = new Set(); /** Collect unique author names from a batch of thinking messages. */ function collectAgentNames(msgs: ChatMessageRecord[]): Set | undefined { const names = new Set(); for (const m of msgs) { if (m.authorName) names.add(m.authorName); } return names.size > 0 ? names : undefined; } for (let i = 0; i < messages.length; i++) { const message = messages[i]; const isLast = i === messages.length - 1; if (message.messageKind === 'thinking') { pendingThinking.push(message); continue; } // Optimistic thinking classification: fold a pending assistant // message into the current activity group when it immediately follows // thinking messages during an active turn, OR when it's the very last // message and no kind has been assigned yet. This prevents the brief // flash where content renders as a full chat message before the // message-reclassified event arrives. if ( busy && isLast && message.role === 'assistant' && message.pending && !message.messageKind ) { pendingThinking.push(message); continue; } if (pendingThinking.length > 0) { const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; if (run) consumedRunIds.add(run.id); items.push({ type: 'turn-activity', thinkingMessages: pendingThinking, run, turnStartedAt: run?.startedAt, agentNames: collectAgentNames(pendingThinking), }); pendingThinking = []; } items.push({ type: 'message', message }); if (message.role === 'user') { lastUserMessageId = message.id; } } if (pendingThinking.length > 0) { const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; if (run) consumedRunIds.add(run.id); items.push({ type: 'turn-activity', thinkingMessages: pendingThinking, run, turnStartedAt: run?.startedAt, agentNames: collectAgentNames(pendingThinking), }); } // If the session is busy but no turn-activity was emitted for the // active run (thinking messages haven't been reclassified yet), inject // one so the panel appears as soon as the run starts producing events. if (lastUserMessageId) { const activeRun = runsByTrigger.get(lastUserMessageId); if (activeRun && !consumedRunIds.has(activeRun.id)) { items.push({ type: 'turn-activity', thinkingMessages: [], run: activeRun, turnStartedAt: activeRun.startedAt }); } } // Tag the last turn-activity panel for each run so only it shows // run-level metadata (git summary, discard button, etc.). const lastPanelIndexByRunId = new Map(); for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type === 'turn-activity' && item.run) { lastPanelIndexByRunId.set(item.run.id, i); } } for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type === 'turn-activity' && item.run) { item.isLastRunPanel = lastPanelIndexByRunId.get(item.run.id) === i; } } return items; }, [session.messages, session.runs, session.status]); const lastTurnActivityIndex = useMemo(() => { for (let i = displayItems.length - 1; i >= 0; i--) { if (displayItems[i].type === 'turn-activity') return i; } return -1; }, [displayItems]); // A turn activity panel is active when the session is running AND the // group contains at least one pending message (currently streaming) or // the associated run is still running. const isTurnActive = useCallback( (item: DisplayItem & { type: 'turn-activity' }, itemIndex: number): boolean => { if (!isSessionBusy) return false; if (itemIndex !== lastTurnActivityIndex) return false; const hasStreamingMessage = item.thinkingMessages.some((m) => m.pending); const runStillRunning = item.run?.status === 'running'; return hasStreamingMessage || !!runStillRunning; }, [isSessionBusy, lastTurnActivityIndex], ); const lastAssistantId = useMemo(() => { for (let i = session.messages.length - 1; i >= 0; i--) { const m = session.messages[i]; if (m.role === 'assistant' && m.messageKind !== 'thinking') return m.id; } return undefined; }, [session.messages]); 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 workflowAgents = useMemo( () => resolveWorkflowAgentNodes(workflow) .map((n) => n.config) .filter((c): c is AgentNodeConfig => c.kind === 'agent'), [workflow], ); const workflowMode = workflow.settings.orchestrationMode ?? 'single'; const isSingleAgent = workflowAgents.length === 1; const primaryAgent = workflowAgents[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 [armedPrompt, setArmedPrompt] = useState(null); // Map assistant message IDs to the actual model that executed the run, but only // when it differs from the session's primary agent model (i.e. a prompt override). const modelOverrideByMessageId = useMemo(() => { const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r])); const map = new Map(); let activeOverrideModel: string | undefined; for (const message of session.messages) { if (message.role === 'user') { const run = runsByTrigger.get(message.id); const runModel = run?.agents[0]?.model; if (runModel && runModel !== primaryAgent?.model) { const resolved = findModel(runModel, availableModels); activeOverrideModel = resolved?.name ?? runModel; } else { activeOverrideModel = undefined; } } else if (activeOverrideModel && message.messageKind !== 'thinking') { map.set(message.id, activeOverrideModel); } } return map; }, [session.messages, session.runs, primaryAgent?.model, availableModels]); const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); const mcpServers = toolingSettings.mcpServers; const lspProfiles = toolingSettings.lspProfiles; const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0; const hasToolCallApproval = workflow.settings.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 : workflow.settings.approvalPolicy?.autoApprovedToolNames ?? [], ), [isApprovalOverridden, session.approvalSettings, workflow.settings.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); setArmedPrompt(null); }, [session.id]); function handleComposerSubmit(content: string) { const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined; const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined; setPendingAttachments([]); if (armedPrompt) { const invocation = { ...armedPrompt.invocation }; if (content.trim()) { invocation.resolvedPrompt = `${invocation.resolvedPrompt}\n\n${content.trim()}`; } setArmedPrompt(null); void onSend('', attachments, messageMode, invocation); } else { 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 · ${workflow.name}` : `${project.name} · ${workflow.name} · ${workflowMode}`} {!isScratchpad && project.git?.status === 'ready' && (() => { const git = project.git; const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD']; if (git.changes) { const bd: string[] = []; if (git.changes.staged > 0) bd.push(`${git.changes.staged} staged`); if (git.changes.unstaged > 0) bd.push(`${git.changes.unstaged} modified`); if (git.changes.untracked > 0) bd.push(`${git.changes.untracked} untracked`); if (bd.length > 0) tipLines.push(bd.join(', ')); } if (git.ahead || git.behind) { const sync: string[] = []; if (git.ahead) sync.push(`${git.ahead} ahead`); if (git.behind) sync.push(`${git.behind} behind`); tipLines.push(sync.join(', ')); } return ( {git.branch ?? git.head?.shortHash ?? 'HEAD'} {git.isDirty && ( )} {(git.ahead ?? 0) > 0 && ↑{git.ahead}} {(git.behind ?? 0) > 0 && ↓{git.behind}} ); })()}

{pendingApproval && (
Awaiting approval {queuedApprovals.length > 0 && ( +{queuedApprovals.length} queued )}
)} {pendingUserInput && !pendingApproval && (
Awaiting your input
)} {isSessionBusy && !pendingApproval && !pendingUserInput && (() => { const label = summarizeSessionActivity(sessionActivity); return (
{label ?? 'Working…'}
); })()} {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{' '} {workflow.name} ) : ( <> Using {workflow.name} in{' '} {project.name} )}

) : (
{/* Branch origin banner */} {session.branchOrigin && ( )}
{displayItems.map((item, itemIndex) => { if (item.type === 'turn-activity') { return (
); } const message = item.message; const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; const isLastAssistant = message.id === lastAssistantId; const phase = getAssistantMessagePhase(session, message); 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 modelOverride = !isUser ? modelOverrideByMessageId.get(message.id) : undefined; const showActions = !isSessionBusy && !message.pending; return (
{isUser ? : }
{message.authorName} {message.isPinned && ( )} {!isUser && phaseLabel && ( {phaseLabel} )} {modelOverride && ( {modelOverride} )} {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)} /> ) : isUser && message.promptInvocation ? ( ) : (
{/* Attachment thumbnails */} {isUser && message.attachments && message.attachments.length > 0 && (
{message.attachments.map((att, attIdx) => isImageAttachment(att) ? ( {getAttachmentDisplayName(att)} ) : (
{getAttachmentDisplayName(att)}
), )}
)} {message.pending && message.content && ( )}
)} {message.pending && !message.content && }
); })}
{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 && ( )} {onGitToggle && !isScratchpad && ( )} {!isScratchpad && promptFiles.length > 0 && ( setArmedPrompt(null)} onSubmit={(promptInvocation) => void onSend('', undefined, undefined, promptInvocation)} 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
)}
); } /* ── Prompt invocation chrome ───────────────────────────────── */ function formatInvocationSourcePath(sourcePath: string): string { const normalized = sourcePath.replace(/\\/g, '/'); const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//; if (ancestorPrefix.test(normalized)) { const segments = normalized.split('/').filter((s) => s !== '..'); return `↑ ${segments.join('/')}`; } return normalized; } function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvocation }) { const [expanded, setExpanded] = useState(false); const isAncestor = invocation.sourcePath.startsWith('..'); return (
{invocation.description && (

{invocation.description}

)}
{isAncestor && ( parent repo )} {formatInvocationSourcePath(invocation.sourcePath)}
{expanded && (
)}
); } /* ── 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'}
); }