import { useRef, useEffect, useState } from 'react' import { useEditorStore } from '../../store/editorStore' import { ChatMessageItem } from './ChatMessageItem' import { ChatInput } from './ChatInput' import { FeedbackPanel } from '../Feedback/FeedbackPanel' import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser' import type { Attachment, TextAnnotation } from '../../types/editor' import './Chat.css' function formatSessionTime(createdAt: number): string { const date = new Date(createdAt) const now = new Date() const isToday = date.toDateString() === now.toDateString() const yesterday = new Date(now) yesterday.setDate(yesterday.getDate() - 1) const isYesterday = date.toDateString() === yesterday.toDateString() if (isToday) return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) if (isYesterday) return 'Yesterday' return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) } export function ChatPanel(): JSX.Element { const { chatHistory, chatSessionsByFile, activeSessionIdByFile, isAILoading, aiError, activeFileContent, activeFilePath, analysisMode, annotations, annotationsByFile, addUserMessage, startAssistantMessage, appendToLastAssistantMessage, setAILoading, setAIError, setAnnotations, linkAnnotationsToMessage, newChat, setActiveSession, rightPanelTab, setRightPanelTab, wholeStoryMode, setWholeStoryMode, storyBibleMode, setStoryBibleMode, } = useEditorStore() const tab = rightPanelTab const setTab = setRightPanelTab const [showHistory, setShowHistory] = useState(false) const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0) const [feedbackNotice, setFeedbackNotice] = useState(null) const scrollRef = useRef(null) const prevAnnotationCountRef = useRef(annotations.length) // Collapse history when switching files useEffect(() => { setShowHistory(false) }, [activeFilePath]) // Auto-switch to Feedback tab the first time annotations appear (0 → >0) useEffect(() => { const prev = prevAnnotationCountRef.current const curr = annotations.length if (prev === 0 && curr > 0) { setTab('feedback') } prevAnnotationCountRef.current = curr }, [annotations]) const sendMessage = async (text: string, attachments: Attachment[]): Promise => { if (!activeFilePath || isAILoading) return setAIError(null) setFeedbackNotice(null) addUserMessage(text, attachments.map(({ name, mimeType }) => ({ name, mimeType }))) startAssistantMessage() setAILoading(true) try { const mode = analysisMode === 'none' ? 'chat' : analysisMode await window.api.streamAIMessage( { mode, documentContent: activeFileContent, documentPath: activeFilePath, conversationHistory: chatHistory .slice(-10) .map((m) => ({ role: m.role, content: m.content })), userMessage: text, attachments: attachments.length > 0 ? attachments : undefined, projectMode: wholeStoryMode, storyBibleMode: storyBibleMode }, (chunk: string) => { appendToLastAssistantMessage(chunk) } ) // After streaming, parse AI response for annotations. // Attachment-driven messages are tagged 'custom' so they appear with a // distinct visual treatment in the Feedback panel. const currentHistory = useEditorStore.getState().chatHistory const lastMsg = currentHistory[currentHistory.length - 1] if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) { const overrideType = attachments.length > 0 ? 'custom' as const : undefined const latestContent = useEditorStore.getState().activeFileContent const { annotations: parsed, droppedCount } = parseAnnotationsFromAIResponse(lastMsg.content, latestContent, overrideType) if (parsed.length > 0) { setAnnotations(parsed) linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id)) } if (droppedCount > 0) { setFeedbackNotice( `${droppedCount} feedback item${droppedCount === 1 ? '' : 's'} couldn't be applied — the referenced text has been edited.` ) } } } catch (err) { const message = err instanceof Error ? err.message : 'An error occurred' setAIError(message) } finally { setAILoading(false) } } // Auto-scroll to bottom when new content arrives useEffect(() => { const el = scrollRef.current if (el) { el.scrollTop = el.scrollHeight } }, [chatHistory]) const hasFile = Boolean(activeFilePath) const allAnnotationsForFile: TextAnnotation[] = (activeFilePath ? annotationsByFile[activeFilePath]?.annotations : undefined) ?? [] const sessions = (activeFilePath ? chatSessionsByFile[activeFilePath] : undefined) ?? [] const activeSessionId = activeFilePath ? activeSessionIdByFile[activeFilePath] : undefined // Subheader is shown in the chat tab once there is at least one session const showSubheader = tab === 'chat' && sessions.length > 0 return (
{/* ── Tab bar ── */}
{/* ── Context toggles: Story bible + Whole story ── */} {tab === 'chat' && hasFile && (
)} {/* ── Chat sub-header: History link + New Chat button ── */} {showSubheader && (
{!showHistory && chatHistory.length > 0 && ( )}
)} {/* ── Pending-attachment indicator ── */} {pendingAttachmentCount > 0 && (
{pendingAttachmentCount} file{pendingAttachmentCount !== 1 ? 's' : ''} attached to next message
)} {/* ── Panel content ── */} {tab === 'feedback' ? ( ) : showHistory ? (
{[...sessions].reverse().map((session) => { const isActive = session.id === activeSessionId const firstUserMsg = session.messages.find(m => m.role === 'user') const summary = firstUserMsg ? firstUserMsg.content.slice(0, 80) + (firstUserMsg.content.length > 80 ? '…' : '') : 'Empty conversation' return ( ) })}
) : ( <>
{!hasFile && (

Open a chapter to start a conversation about it.

)} {hasFile && chatHistory.length === 0 && (

Ask anything about the current chapter — passive voice, plot, character, style...

)} {chatHistory.map((msg) => { const linkedAnnotations = (msg.annotationIds ?? []) .map(id => allAnnotationsForFile.find(a => a.id === id)) .filter((a): a is TextAnnotation => a !== undefined) return ( ) })} {isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
)} {aiError && (
{aiError}
)} {feedbackNotice && (
{feedbackNotice}
)}
)}
) }