import { useEffect, useRef, useState } from 'react'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { projectRawUrl } from '../providers/registry'; import type { ChatAttachment, ChatMessage, Conversation, ProjectFile } from '../types'; import { AssistantMessage } from './AssistantMessage'; import { ChatComposer, type ChatComposerHandle } from './ChatComposer'; import { Icon } from './Icon'; type TranslateFn = (key: keyof Dict, vars?: Record) => string; // Featured starter prompts shown on the empty chat. Clicking one fills // the composer (does not auto-send) so users can tweak before sending. // Each prompt is intentionally dense — it should showcase ambitious // layout, typographic, and information-design moves rather than a // generic landing page. const EXAMPLE_PROMPT_KEYS: Array<{ icon: string; titleKey: keyof Dict; tagKey: keyof Dict; promptKey: keyof Dict; }> = [ { icon: '▤', titleKey: 'chat.example1Title', tagKey: 'chat.example1Tag', promptKey: 'chat.example1Prompt', }, { icon: '▦', titleKey: 'chat.example2Title', tagKey: 'chat.example2Tag', promptKey: 'chat.example2Prompt', }, { icon: '◈', titleKey: 'chat.example3Title', tagKey: 'chat.example3Tag', promptKey: 'chat.example3Prompt', }, ]; interface Props { messages: ChatMessage[]; streaming: boolean; error: string | null; projectId: string | null; projectFiles: ProjectFile[]; // Names that exist in the project folder. Tool cards and chips use this // set to decide whether a path can be opened as a tab. projectFileNames?: Set; onEnsureProject: () => Promise; onSend: (prompt: string, attachments: ChatAttachment[]) => void; onStop: () => void; // Click-to-open chain: passes a basename up to ProjectView, which sets // FileWorkspace's openRequest. Tool cards, attachment chips, and // produced-file chips all call this. onRequestOpenFile?: (name: string) => void; initialDraft?: string; // Question-form submissions become a normal user message; the parent // routes that text through onSend (no attachments). onSubmitForm?: (text: string) => void; // Header "+" button — kicks off ProjectView's create-conversation flow. onNewConversation?: () => void; // Conversation list that used to live in the topbar. The chat tab now // owns the list so users can browse + switch conversations without // leaving the pane. conversations: Conversation[]; activeConversationId: string | null; onSelectConversation: (id: string) => void; onDeleteConversation: (id: string) => void; onRenameConversation?: (id: string, title: string) => void; // Composer settings/CLI button forwards to here. The dialog lives in App // (it owns the AppConfig lifecycle) so we just pass the open trigger. onOpenSettings?: () => void; } type Tab = 'chat' | 'comments'; export function ChatPane({ messages, streaming, error, projectId, projectFiles, projectFileNames, onEnsureProject, onSend, onStop, onRequestOpenFile, initialDraft, onSubmitForm, onNewConversation, conversations, activeConversationId, onSelectConversation, onDeleteConversation, onRenameConversation, onOpenSettings, }: Props) { const t = useT(); const logRef = useRef(null); const historyWrapRef = useRef(null); const composerRef = useRef(null); const [tab, setTab] = useState('chat'); const [showConvList, setShowConvList] = useState(false); const [scrolledFromBottom, setScrolledFromBottom] = useState(false); const lastAssistantId = [...messages].reverse().find((m) => m.role === 'assistant')?.id; // Map each assistant message id to the user message that follows it // (if any) so QuestionFormView can render its locked "answered" state // with the user's picks. const nextUserContentByAssistantId = (() => { const map = new Map(); for (let i = 0; i < messages.length - 1; i++) { const m = messages[i]!; const next = messages[i + 1]!; if (m.role === 'assistant' && next.role === 'user') { map.set(m.id, next.content); } } return map; })(); useEffect(() => { const el = logRef.current; if (!el) return; // Auto-scroll only when we're already pinned near the bottom — preserves // a user's scrollback position when they're reading earlier output while // a new turn streams in. const distance = el.scrollHeight - el.scrollTop - el.clientHeight; if (distance < 80) { el.scrollTop = el.scrollHeight; } }, [messages, error]); useEffect(() => { const el = logRef.current; if (!el) return; function onScroll() { const target = logRef.current; if (!target) return; const distance = target.scrollHeight - target.scrollTop - target.clientHeight; setScrolledFromBottom(distance > 120); } el.addEventListener('scroll', onScroll); return () => el.removeEventListener('scroll', onScroll); }, []); // Close the conversation history dropdown on outside click / Escape. useEffect(() => { if (!showConvList) return; function onPointer(e: MouseEvent) { const target = e.target as Node; if (historyWrapRef.current?.contains(target)) return; setShowConvList(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setShowConvList(false); } document.addEventListener('mousedown', onPointer); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onPointer); document.removeEventListener('keydown', onKey); }; }, [showConvList]); const activeConversation = conversations.find((c) => c.id === activeConversationId) ?? null; function jumpToBottom() { const el = logRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); } return (
{showConvList ? (
{t('chat.conversationsHeading')} {onNewConversation ? ( ) : null}
{conversations.length === 0 ? (
{t('chat.emptyConversations')}
) : ( conversations.map((c) => ( { onSelectConversation(c.id); setShowConvList(false); }} onDelete={() => onDeleteConversation(c.id)} onRename={onRenameConversation} t={t} /> )) )}
) : null}
{tab === 'chat' ? ( <>
{messages.length === 0 ? (
{t('chat.startTitle')} {t('chat.startHint')}
{EXAMPLE_PROMPT_KEYS.map((ex, i) => { const title = t(ex.titleKey); const tag = t(ex.tagKey); const prompt = t(ex.promptKey); return ( ); })}
) : null} {messages.map((m) => m.role === 'user' ? ( ) : ( ), )} {error ?
{error}
: null}
{scrolledFromBottom ? ( ) : null}
) : null}
); } function ConversationRow({ conversation, active, onSelect, onDelete, onRename, t, }: { conversation: Conversation; active: boolean; onSelect: () => void; onDelete: () => void; onRename?: (id: string, title: string) => void; t: TranslateFn; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(conversation.title ?? ''); const displayTitle = conversation.title || t('chat.untitledConversation'); return (
{editing && onRename ? ( setDraft(e.target.value)} onBlur={() => { onRename(conversation.id, draft); setEditing(false); }} onKeyDown={(e) => { if (e.key === 'Enter') { onRename(conversation.id, draft); setEditing(false); } else if (e.key === 'Escape') { setEditing(false); } }} style={{ flex: 1, padding: '2px 6px', fontSize: 12 }} /> ) : ( )} {relTime(conversation.updatedAt, t)}
); } function UserMessage({ message, projectId, projectFileNames, onRequestOpenFile, t, }: { message: ChatMessage; projectId: string | null; projectFileNames?: Set; onRequestOpenFile?: (name: string) => void; t: TranslateFn; }) { const attachments = message.attachments ?? []; return (
{t('chat.you')}
{attachments.length > 0 ? (
{attachments.map((a) => { const baseName = a.path.split('/').pop() || a.path; const openable = !!onRequestOpenFile && (projectFileNames ? projectFileNames.has(baseName) : true); const handleOpen = openable ? () => onRequestOpenFile?.(baseName) : undefined; return ( ); })}
) : null}
{message.content}
); } function relTime(ts: number, t: TranslateFn): string { const diff = Date.now() - ts; const min = 60_000; const hr = 60 * min; const day = 24 * hr; if (diff < min) return t('common.now'); if (diff < hr) return t('common.minutesShort', { n: Math.floor(diff / min) }); if (diff < day) return t('common.hoursShort', { n: Math.floor(diff / hr) }); if (diff < 7 * day) return t('common.daysShort', { n: Math.floor(diff / day) }); return new Date(ts).toLocaleDateString(); }