:lightning: chat rendering

This commit is contained in:
TC
2026-06-14 16:49:08 +10:00
parent e470f4d3e8
commit a78ae0fdc0
4 changed files with 111 additions and 36 deletions

View File

@@ -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

View File

@@ -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<string | null>(null)
const streamingContentRef = useRef('')
const streamingRafRef = useRef<number | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const scrollRafRef = useRef<number | null>(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 {
</button>
</div>
{/* ── Chat sub-header: History link + New Chat button ── */}
{showSubheader && (
<div className="chat-subheader">
@@ -219,7 +235,7 @@ export function ChatPanel(): JSX.Element {
{!hasFile && (
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
)}
{hasFile && chatHistory.length === 0 && (
{hasFile && chatHistory.length === 0 && !isAILoading && (
<p className="chat-placeholder">
Ask anything about the current chapter passive voice, plot, character, style...
</p>
@@ -236,11 +252,15 @@ export function ChatPanel(): JSX.Element {
/>
)
})}
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
<div className="chat-typing">
<span />
<span />
<span />
{isAILoading && (
<div className="chat-message chat-message-assistant">
<div className="chat-message-label">Editor AI</div>
<div className="chat-message-content">
{streamingContent
? <span>{streamingContent}</span>
: <div className="chat-typing"><span /><span /><span /></div>
}
</div>
</div>
)}
{aiError && (

View File

@@ -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<number | null>(null)
const [analyzeOpen, setAnalyzeOpen] = useState(false)
const analyzeButtonRef = useRef<HTMLButtonElement>(null)
const analyzeMenuRef = useRef<HTMLDivElement>(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) {

View File

@@ -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<EditorState>((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<EditorState>((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 }