:lightning: chat rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user