286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
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<string | null>(null)
|
|
const scrollRef = useRef<HTMLDivElement>(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<void> => {
|
|
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 (
|
|
<div className="chat-panel">
|
|
{/* ── Tab bar ── */}
|
|
<div className="chat-tabs">
|
|
<button
|
|
className={`chat-tab${tab === 'chat' ? ' chat-tab-active' : ''}`}
|
|
onClick={() => setTab('chat')}
|
|
>
|
|
Chat
|
|
</button>
|
|
<button
|
|
className={`chat-tab${tab === 'feedback' ? ' chat-tab-active' : ''}`}
|
|
onClick={() => setTab('feedback')}
|
|
>
|
|
Feedback
|
|
{annotations.length > 0 && (
|
|
<span className="chat-tab-badge">{annotations.length}</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Context toggles: Story bible + Whole story ── */}
|
|
{tab === 'chat' && hasFile && (
|
|
<div className="chat-context-bar">
|
|
<button
|
|
className={`chat-project-toggle${storyBibleMode ? ' chat-project-toggle--active' : ''}`}
|
|
onClick={() => setStoryBibleMode(!storyBibleMode)}
|
|
title={storyBibleMode ? 'Story bible on — Claude sees your reference notes' : 'Enable story bible context'}
|
|
>
|
|
{storyBibleMode ? '◉' : '○'} Story bible
|
|
</button>
|
|
<button
|
|
className={`chat-project-toggle${wholeStoryMode ? ' chat-project-toggle--active' : ''}`}
|
|
onClick={() => setWholeStoryMode(!wholeStoryMode)}
|
|
title={wholeStoryMode ? 'Whole story on — Claude sees all chapters' : 'Enable whole story context'}
|
|
>
|
|
{wholeStoryMode ? '◉' : '○'} Whole story
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Chat sub-header: History link + New Chat button ── */}
|
|
{showSubheader && (
|
|
<div className="chat-subheader">
|
|
<button
|
|
className={`chat-history-link${showHistory ? ' chat-history-link-active' : ''}`}
|
|
onClick={() => setShowHistory(v => !v)}
|
|
>
|
|
{showHistory ? 'Back to chat' : `History${sessions.length > 1 ? ` (${sessions.length})` : ''}`}
|
|
</button>
|
|
{!showHistory && chatHistory.length > 0 && (
|
|
<button className="chat-new-btn" onClick={newChat} title="Start a new conversation">
|
|
+ New Chat
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Pending-attachment indicator ── */}
|
|
{pendingAttachmentCount > 0 && (
|
|
<div className="chat-attachment-indicator">
|
|
<span className="chat-attachment-indicator-icon">⌁</span>
|
|
{pendingAttachmentCount} file{pendingAttachmentCount !== 1 ? 's' : ''} attached to next message
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Panel content ── */}
|
|
{tab === 'feedback' ? (
|
|
<FeedbackPanel />
|
|
) : showHistory ? (
|
|
<div className="chat-history-list">
|
|
{[...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 (
|
|
<button
|
|
key={session.id}
|
|
className={`chat-history-item${isActive ? ' chat-history-item-active' : ''}`}
|
|
onClick={() => {
|
|
setActiveSession(session.id)
|
|
setShowHistory(false)
|
|
}}
|
|
>
|
|
<span className="chat-history-time">{formatSessionTime(session.createdAt)}</span>
|
|
<span className="chat-history-summary">{summary}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="chat-messages" ref={scrollRef}>
|
|
{!hasFile && (
|
|
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
|
|
)}
|
|
{hasFile && chatHistory.length === 0 && (
|
|
<p className="chat-placeholder">
|
|
Ask anything about the current chapter — passive voice, plot, character, style...
|
|
</p>
|
|
)}
|
|
{chatHistory.map((msg) => {
|
|
const linkedAnnotations = (msg.annotationIds ?? [])
|
|
.map(id => allAnnotationsForFile.find(a => a.id === id))
|
|
.filter((a): a is TextAnnotation => a !== undefined)
|
|
return (
|
|
<ChatMessageItem
|
|
key={msg.id}
|
|
message={msg}
|
|
linkedAnnotations={linkedAnnotations}
|
|
/>
|
|
)
|
|
})}
|
|
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
|
|
<div className="chat-typing">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
)}
|
|
{aiError && (
|
|
<div className="chat-error">{aiError}</div>
|
|
)}
|
|
{feedbackNotice && (
|
|
<div className="chat-info-notice">{feedbackNotice}</div>
|
|
)}
|
|
</div>
|
|
|
|
<ChatInput
|
|
onSend={sendMessage}
|
|
onAttachmentsChange={setPendingAttachmentCount}
|
|
disabled={!hasFile || isAILoading}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|