import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AIChatEntry, PendingPermission } from '../../hooks/useAIChat'; import { hasNonInterruptibleSession, type AIProviderOption } from '../../utils/aiProvider'; import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat'; import { OverlayScrollArea } from '../OverlayScrollArea'; import { SparklesIcon } from '../SparklesIcon'; import { AIProviderBar } from './AIProviderBar'; import { SessionAskActions, SessionAskNote, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice'; import { submitHint } from '../../utils/platform'; interface DocumentAIChatPanelProps { messages: AIChatEntry[]; isCreatingSession: boolean; isStreaming: boolean; onAskGeneral?: (question: string) => void; /** Stop the in-flight answer. When provided, the input shows a Stop button while streaming. */ onStop?: () => void; permissionRequests?: PendingPermission[]; onRespondToPermission?: (requestId: string, allow: boolean) => void; aiProviders?: AIProviderOption[]; aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; /** "Ask this session": the reviewer's choice on a busy answer. */ onSessionAskAction?: (questionId: string, action: SessionAskAction) => void; /** Optional note under the provider bar on how to get "Ask this session" (askSessionSetupHint). */ askSessionSetupHint?: string | null; } function truncate(text: string, max = 180): string { if (text.length <= max) return text; return `${text.slice(0, max).trimEnd()}...`; } function formatToolInput(toolName: string, input: Record): string | null { if (!input || Object.keys(input).length === 0) return null; if (toolName === 'Bash' && typeof input.command === 'string') { return input.command; } if ((toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') && typeof input.file_path === 'string') { return input.file_path; } if (toolName === 'Glob' && typeof input.pattern === 'string') { return input.pattern; } if (toolName === 'Grep' && typeof input.pattern === 'string') { const path = typeof input.path === 'string' ? ` in ${input.path}` : ''; return `${input.pattern}${path}`; } if ((toolName === 'WebFetch' || toolName === 'WebSearch') && typeof input.url === 'string') { return input.url; } try { return truncate(JSON.stringify(input), 240); } catch { return String(input); } } export const DocumentAIChatPanel: React.FC = ({ messages, isCreatingSession, isStreaming, onAskGeneral, onStop, permissionRequests = [], onRespondToPermission, aiProviders = [], aiConfig, onAIConfigChange, onSessionAskAction, askSessionSetupHint, }) => { // A session that is never interrupted (T3 Code): Stop only stops listening. const listenOnly = hasNonInterruptibleSession(aiProviders); const scrollRef = useRef(null); const [generalInput, setGeneralInput] = useState(''); const latestMessage = messages[messages.length - 1]; const latestResponseText = latestMessage?.response.text ?? ''; useEffect(() => { if (!scrollRef.current) return; const last = scrollRef.current.querySelector('[data-ai-message]:last-child'); last?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }, [messages.length, latestResponseText]); const handleGeneralSubmit = useCallback(() => { const question = generalInput.trim(); if (!question || !onAskGeneral) return; onAskGeneral(question); setGeneralInput(''); }, [generalInput, onAskGeneral]); return (
{messages.length === 0 && !isCreatingSession && (

{onAskGeneral ? ( <>Select text and click Ask AI, or ask a general question below. ) : ( <>Select text and click Ask AI. )}

)} {isCreatingSession && messages.length === 0 && (
Starting AI session...
)} {messages.map(entry => ( ))}
{/* Pending permission requests — pinned just above the input/model bar so the user sees them right where they act, not buried at the top. */} {permissionRequests.filter(p => !p.decided).length > 0 && (
{permissionRequests.filter(p => !p.decided).map(permission => ( {})} /> ))}
)} onAIConfigChange?.({ providerId })} onModelChange={(model) => onAIConfigChange?.({ model })} onReasoningEffortChange={(reasoningEffort) => onAIConfigChange?.({ reasoningEffort })} setupHint={askSessionSetupHint} /> {onAskGeneral && ( )}
); }; const DocumentQAPair = memo<{ entry: AIChatEntry; onSessionAskAction?: (questionId: string, action: SessionAskAction) => void; canInterrupt?: boolean; }>(({ entry, onSessionAskAction, canInterrupt }) => { const { question, response } = entry; const renderedResponse = useMemo( () => response.text ? renderChatMarkdown(response.text) : null, [response.text], ); const scope = question.scope; return (
{scope?.kind === 'selection' && ( selection )} {scope?.label && ( {scope.label} )}
{formatRelativeTime(question.createdAt)}
{scope?.text && (

{truncate(scope.text)}

)}

{question.prompt}

{response.error ? ( <>

{response.error}

onSessionAskAction(question.id, action) : undefined} /> ) : response.text ? (
{renderedResponse} {response.isStreaming && }
) : response.status ? ( ) : response.isStreaming ? ( Thinking... ) : null} {!response.error && }
); }); const PermissionCard: React.FC<{ permission: PendingPermission; onRespond: (requestId: string, allow: boolean) => void; }> = ({ permission, onRespond }) => { const label = permission.title || permission.displayName || permission.toolName; const toolInput = formatToolInput(permission.toolName, permission.toolInput); return (

Permission Request

{label}

{toolInput && (

{toolInput}

)} {permission.description && (

{permission.description}

)}
); }; const GeneralInput: React.FC<{ value: string; onChange: (value: string) => void; onSubmit: () => void; disabled?: boolean; isStreaming?: boolean; onStop?: () => void; observationOnly?: boolean; }> = ({ value, onChange, onSubmit, disabled, isStreaming, onStop, observationOnly = false }) => { const textareaRef = useRef(null); useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.min(el.scrollHeight, 120)}px`; }, [value]); return (