From a78ae0fdc0d19c538f81eaf398083ccf5f29b726 Mon Sep 17 00:00:00 2001 From: TC Date: Sun, 14 Jun 2026 16:49:08 +1000 Subject: [PATCH] :lightning: chat rendering --- .../components/AIChat/ChatMessageItem.tsx | 3 +- src/renderer/components/AIChat/ChatPanel.tsx | 64 ++++++++++++------- .../components/Toolbar/AnalysisToolbar.tsx | 61 ++++++++++++++---- src/renderer/store/editorStore.ts | 19 ++++++ 4 files changed, 111 insertions(+), 36 deletions(-) diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index 994ac33..f7b3c95 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -32,7 +32,8 @@ interface Props { } function ChatMessageItemInner({ message, linkedAnnotations }: Props): JSX.Element { - const { activeFilePath, markSaved } = useEditorStore() + const activeFilePath = useEditorStore(s => s.activeFilePath) + const markSaved = useEditorStore(s => s.markSaved) const html = useMemo(() => { if (message.role !== 'assistant' || !message.content) return null diff --git a/src/renderer/components/AIChat/ChatPanel.tsx b/src/renderer/components/AIChat/ChatPanel.tsx index 43521ce..9d86d4e 100644 --- a/src/renderer/components/AIChat/ChatPanel.tsx +++ b/src/renderer/components/AIChat/ChatPanel.tsx @@ -32,8 +32,9 @@ export function ChatPanel(): JSX.Element { annotations, annotationsByFile, addUserMessage, - startAssistantMessage, - appendToLastAssistantMessage, + finalizeAssistantMessage, + setStreamingContent, + streamingContent, setAILoading, setAIError, setAnnotations, @@ -49,6 +50,8 @@ export function ChatPanel(): JSX.Element { const [showHistory, setShowHistory] = useState(false) const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0) const [feedbackNotice, setFeedbackNotice] = useState(null) + const streamingContentRef = useRef('') + const streamingRafRef = useRef(null) const scrollRef = useRef(null) const scrollRafRef = useRef(null) const prevAnnotationCountRef = useRef(annotations.length) @@ -71,8 +74,8 @@ export function ChatPanel(): JSX.Element { setAIError(null) setFeedbackNotice(null) + streamingContentRef.current = '' addUserMessage(text, attachments.map(({ name, mimeType }) => ({ name, mimeType }))) - startAssistantMessage() setAILoading(true) try { @@ -89,22 +92,37 @@ export function ChatPanel(): JSX.Element { attachments: attachments.length > 0 ? attachments : undefined }, (chunk: string) => { - appendToLastAssistantMessage(chunk) + streamingContentRef.current += chunk + if (streamingRafRef.current === null) { + streamingRafRef.current = requestAnimationFrame(() => { + streamingRafRef.current = null + setStreamingContent(streamingContentRef.current) + }) + } } ) - // 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) { + // Cancel any pending rAF and commit to store once + if (streamingRafRef.current !== null) { + cancelAnimationFrame(streamingRafRef.current) + streamingRafRef.current = null + } + const finalContent = streamingContentRef.current + streamingContentRef.current = '' + + if (finalContent.length > 0) { + finalizeAssistantMessage(finalContent) + const overrideType = attachments.length > 0 ? 'custom' as const : undefined const latestContent = useEditorStore.getState().activeFileContent - const { annotations: parsed, droppedCount } = parseAnnotationsFromAIResponse(lastMsg.content, latestContent, overrideType) + const { annotations: parsed, droppedCount } = parseAnnotationsFromAIResponse(finalContent, latestContent, overrideType) if (parsed.length > 0) { + const currentHistory = useEditorStore.getState().chatHistory + const lastMsg = currentHistory[currentHistory.length - 1] setAnnotations(parsed) - linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id)) + if (lastMsg?.role === 'assistant') { + linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id)) + } } if (droppedCount > 0) { setFeedbackNotice( @@ -120,7 +138,7 @@ export function ChatPanel(): JSX.Element { } } - // Auto-scroll to bottom when new content arrives (coalesced to one scroll per animation frame) + // Auto-scroll to bottom when new content arrives useEffect(() => { if (scrollRafRef.current !== null) return scrollRafRef.current = requestAnimationFrame(() => { @@ -128,7 +146,7 @@ export function ChatPanel(): JSX.Element { const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight }) - }, [chatHistory]) + }, [chatHistory, streamingContent]) const hasFile = Boolean(activeFilePath) const allAnnotationsForFile: TextAnnotation[] = @@ -137,7 +155,6 @@ export function ChatPanel(): JSX.Element { 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 ( @@ -161,7 +178,6 @@ export function ChatPanel(): JSX.Element { - {/* ── Chat sub-header: History link + New Chat button ── */} {showSubheader && (
@@ -219,7 +235,7 @@ export function ChatPanel(): JSX.Element { {!hasFile && (

Open a chapter to start a conversation about it.

)} - {hasFile && chatHistory.length === 0 && ( + {hasFile && chatHistory.length === 0 && !isAILoading && (

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

@@ -236,11 +252,15 @@ export function ChatPanel(): JSX.Element { /> ) })} - {isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && ( -
- - - + {isAILoading && ( +
+
Editor AI
+
+ {streamingContent + ? {streamingContent} + :
+ } +
)} {aiError && ( diff --git a/src/renderer/components/Toolbar/AnalysisToolbar.tsx b/src/renderer/components/Toolbar/AnalysisToolbar.tsx index cfa10dd..ff5775d 100644 --- a/src/renderer/components/Toolbar/AnalysisToolbar.tsx +++ b/src/renderer/components/Toolbar/AnalysisToolbar.tsx @@ -120,8 +120,8 @@ export function AnalysisToolbar(): JSX.Element { clearAnnotations, setAnalysisMode, addUserMessage, - startAssistantMessage, - appendToLastAssistantMessage, + finalizeAssistantMessage, + setStreamingContent, setAILoading, setAIError, linkAnnotationsToMessage, @@ -138,6 +138,9 @@ export function AnalysisToolbar(): JSX.Element { selectionWordCount } = useEditorStore() + const streamingContentRef = useRef('') + const streamingRafRef = useRef(null) + const [analyzeOpen, setAnalyzeOpen] = useState(false) const analyzeButtonRef = useRef(null) const analyzeMenuRef = useRef(null) @@ -223,8 +226,8 @@ export function AnalysisToolbar(): JSX.Element { setAIError(null) setRightPanelTab('chat') + streamingContentRef.current = '' addUserMessage(prompt) - startAssistantMessage({ bibleGeneration: true }) setAILoading(true) try { @@ -239,9 +242,25 @@ export function AnalysisToolbar(): JSX.Element { userMessage: prompt }, (chunk: string) => { - appendToLastAssistantMessage(chunk) + streamingContentRef.current += chunk + if (streamingRafRef.current === null) { + streamingRafRef.current = requestAnimationFrame(() => { + streamingRafRef.current = null + setStreamingContent(streamingContentRef.current) + }) + } } ) + + if (streamingRafRef.current !== null) { + cancelAnimationFrame(streamingRafRef.current) + streamingRafRef.current = null + } + const finalContent = streamingContentRef.current + streamingContentRef.current = '' + if (finalContent.length > 0) { + finalizeAssistantMessage(finalContent, { bibleGeneration: true }) + } } catch (err) { setAIError(err instanceof Error ? err.message : 'Generation failed') } finally { @@ -286,8 +305,8 @@ export function AnalysisToolbar(): JSX.Element { ? 'Please identify every past progressive construction (was/were + verb-ing) in this chapter that would be stronger in simple past.' : 'Please give me an honest critique of this chapter.' + streamingContentRef.current = '' addUserMessage(prompt) - startAssistantMessage() setAILoading(true) try { @@ -302,26 +321,42 @@ export function AnalysisToolbar(): JSX.Element { userMessage: prompt }, (chunk: string) => { - appendToLastAssistantMessage(chunk) + streamingContentRef.current += chunk + if (streamingRafRef.current === null) { + streamingRafRef.current = requestAnimationFrame(() => { + streamingRafRef.current = null + setStreamingContent(streamingContentRef.current) + }) + } } ) - // Parse annotations from response - const currentHistory = useEditorStore.getState().chatHistory - const lastMsg = currentHistory[currentHistory.length - 1] - if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) { - // Force annotation type for modes where the classifier might mis-label. + if (streamingRafRef.current !== null) { + cancelAnimationFrame(streamingRafRef.current) + streamingRafRef.current = null + } + const finalContent = streamingContentRef.current + streamingContentRef.current = '' + + if (finalContent.length > 0) { + finalizeAssistantMessage(finalContent) + + // Parse annotations from response const overrideType = mode === 'show_tell' ? 'show_tell' : mode === 'weak_verbs' ? 'weak_verbs' : mode === 'cliches' ? 'cliches' : mode === 'past_progressive' ? 'past_progressive' : undefined - const { annotations: newAnnotations } = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent, overrideType) + const { annotations: newAnnotations } = parseAnnotationsFromAIResponse(finalContent, activeFileContent, overrideType) if (newAnnotations.length > 0) { + const currentHistory = useEditorStore.getState().chatHistory + const lastMsg = currentHistory[currentHistory.length - 1] const existing = useEditorStore.getState().annotations.filter((a) => a.type !== mode) setAnnotations([...existing, ...newAnnotations]) - linkAnnotationsToMessage(lastMsg.id, newAnnotations.map(a => a.id)) + if (lastMsg?.role === 'assistant') { + linkAnnotationsToMessage(lastMsg.id, newAnnotations.map(a => a.id)) + } } } } catch (err) { diff --git a/src/renderer/store/editorStore.ts b/src/renderer/store/editorStore.ts index a87f35f..1d1a4e5 100644 --- a/src/renderer/store/editorStore.ts +++ b/src/renderer/store/editorStore.ts @@ -28,6 +28,9 @@ interface EditorState { addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void startAssistantMessage: (opts?: { bibleGeneration?: boolean }) => void appendToLastAssistantMessage: (chunk: string) => void + finalizeAssistantMessage: (content: string, opts?: { bibleGeneration?: boolean }) => void + streamingContent: string + setStreamingContent: (content: string) => void setAILoading: (loading: boolean) => void setAIError: (error: string | null) => void newChat: () => void @@ -216,6 +219,7 @@ export const useEditorStore = create((set, get) => ({ chatHistory: [], isAILoading: false, aiError: null, + streamingContent: '', addUserMessage: (text, attachments?) => { const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text, attachments } @@ -274,6 +278,21 @@ export const useEditorStore = create((set, get) => ({ }) }, + finalizeAssistantMessage: (content, opts?) => { + const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content, bibleGeneration: opts?.bibleGeneration } + set((s) => { + const history = [...s.chatHistory, msg] + if (!s.activeFilePath) return { chatHistory: history, streamingContent: '' } + const activeId = s.activeSessionIdByFile[s.activeFilePath] + const sessions = (s.chatSessionsByFile[s.activeFilePath] ?? []).map(sess => + sess.id === activeId ? { ...sess, messages: history } : sess + ) + return { chatHistory: history, chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: sessions }, streamingContent: '' } + }) + }, + + setStreamingContent: (content) => set({ streamingContent: content }), + setAILoading: (isAILoading) => { set((s) => { if (isAILoading) return { isAILoading }