From ff6e946529d806b3d5b1c3921fe03c1999dcb46d Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Sat, 28 Feb 2026 12:06:36 +1000 Subject: [PATCH] :sparkles: attached suggestions to chats --- src/renderer/components/AIChat/Chat.css | 66 +++++++++++++++++++ .../components/AIChat/ChatMessageItem.tsx | 47 +++++++++++-- src/renderer/components/AIChat/ChatPanel.tsx | 22 +++++-- .../components/Editor/MarkdownEditor.tsx | 8 ++- src/renderer/store/editorStore.ts | 58 +++++++++++++++- src/renderer/types/editor.ts | 2 + src/renderer/utils/annotationParser.ts | 3 +- 7 files changed, 192 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/AIChat/Chat.css b/src/renderer/components/AIChat/Chat.css index fbbccc7..9784d5e 100644 --- a/src/renderer/components/AIChat/Chat.css +++ b/src/renderer/components/AIChat/Chat.css @@ -475,3 +475,69 @@ .chat-send-btn:not(:disabled):hover { opacity: 0.85; } + +/* ── Suggestion summary block inside assistant messages ─── */ +.chat-message-suggestions { + margin-top: 6px; + border-top: 1px solid var(--border); + padding-top: 7px; +} + +.chat-suggestions-label { + display: block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 5px; +} + +.chat-suggestion-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-suggestion-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.chat-suggestion-badge { + display: inline-block; + border-radius: 3px; + padding: 1px 5px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #fff; + flex-shrink: 0; +} + +.chat-suggestion-excerpt { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.chat-suggestion-applied { + display: inline-block; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #1a1208; + background: rgba(30, 200, 120, 0.85); + border-radius: 3px; + padding: 1px 5px; + flex-shrink: 0; +} diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index ccae418..8afcaf7 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { marked } from 'marked' import DOMPurify from 'dompurify' -import type { ChatMessage } from '../../types/editor' +import type { ChatMessage, TextAnnotation } from '../../types/editor' marked.setOptions({ breaks: true }) @@ -11,11 +11,22 @@ function attachmentIcon(mimeType: string): string { return '📝' } -interface Props { - message: ChatMessage +function badgeColor(type: TextAnnotation['type']): string { + switch (type) { + case 'passive_voice': return 'rgba(255, 200, 0, 0.75)' + case 'consistency': return 'rgba(220, 80, 80, 0.75)' + case 'style': return 'rgba(80, 160, 255, 0.75)' + case 'critique': return 'rgba(160, 80, 220, 0.75)' + case 'custom': return 'rgba(30, 200, 150, 0.8)' + } } -export function ChatMessageItem({ message }: Props): JSX.Element { +interface Props { + message: ChatMessage + linkedAnnotations?: TextAnnotation[] +} + +export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Element { const html = useMemo(() => { if (message.role !== 'assistant' || !message.content) return null const raw = marked.parse(message.content) as string @@ -47,6 +58,34 @@ export function ChatMessageItem({ message }: Props): JSX.Element { message.content || )} + + {linkedAnnotations && linkedAnnotations.length > 0 && ( +
+ + {linkedAnnotations.length} suggestion{linkedAnnotations.length !== 1 ? 's' : ''} created + +
+ {linkedAnnotations.map(ann => ( +
+ + {ann.type.replace(/_/g, ' ')} + + + "{ann.matchedText.length > 40 + ? ann.matchedText.slice(0, 38) + '…' + : ann.matchedText}" + + {ann.applied && ( + Applied + )} +
+ ))} +
+
+ )} ) } diff --git a/src/renderer/components/AIChat/ChatPanel.tsx b/src/renderer/components/AIChat/ChatPanel.tsx index d93cbfc..4000384 100644 --- a/src/renderer/components/AIChat/ChatPanel.tsx +++ b/src/renderer/components/AIChat/ChatPanel.tsx @@ -4,7 +4,7 @@ import { ChatMessageItem } from './ChatMessageItem' import { ChatInput } from './ChatInput' import { FeedbackPanel } from '../Feedback/FeedbackPanel' import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser' -import type { Attachment } from '../../types/editor' +import type { Attachment, TextAnnotation } from '../../types/editor' import './Chat.css' type TabId = 'chat' | 'feedback' @@ -18,12 +18,14 @@ export function ChatPanel(): JSX.Element { activeFilePath, analysisMode, annotations, + annotationsByFile, addUserMessage, startAssistantMessage, appendToLastAssistantMessage, setAILoading, setAIError, setAnnotations, + linkAnnotationsToMessage, clearChat } = useEditorStore() @@ -78,6 +80,7 @@ export function ChatPanel(): JSX.Element { const parsed = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent, overrideType) if (parsed.length > 0) { setAnnotations(parsed) + linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id)) } } } catch (err) { @@ -97,6 +100,8 @@ export function ChatPanel(): JSX.Element { }, [chatHistory]) const hasFile = Boolean(activeFilePath) + const allAnnotationsForFile: TextAnnotation[] = + (activeFilePath ? annotationsByFile[activeFilePath]?.annotations : undefined) ?? [] return (
@@ -148,9 +153,18 @@ export function ChatPanel(): JSX.Element { Ask anything about the current chapter — passive voice, plot, character, style...

)} - {chatHistory.map((msg) => ( - - ))} + {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 === '' && (
diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index bc84edf..877117a 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -155,11 +155,11 @@ export function scrollToAnnotation(ann: TextAnnotation): void { }) } -// Apply a suggestion to the document and remove that annotation +// Apply a suggestion to the document and mark that annotation as applied export function applyAnnotation(ann: TextAnnotation, suggestion: string): void { const view = currentEditorView if (!view) return - const { annotations: anns, setAnnotations } = useEditorStore.getState() + const { annotations: anns, markAnnotationApplied } = useEditorStore.getState() const changeSpec = { from: ann.from, to: ann.to, insert: suggestion } // Map surviving annotation positions through the text change so // their from/to reflect the new document offsets. @@ -173,7 +173,9 @@ export function applyAnnotation(ann: TextAnnotation, suggestion: string): void { changes: changeSpec, effects: setAnnotationsEffect.of(remaining) }) - setAnnotations(remaining) + // Mark as applied in annotationsByFile (preserves it for chat history) and + // remove from the active annotations list. + markAnnotationApplied(ann.id) tooltipAnalysisCache.delete(ann.id) } diff --git a/src/renderer/store/editorStore.ts b/src/renderer/store/editorStore.ts index 40ed097..40ec258 100644 --- a/src/renderer/store/editorStore.ts +++ b/src/renderer/store/editorStore.ts @@ -37,6 +37,8 @@ interface EditorState { setAnnotations: (annotations: TextAnnotation[]) => void removeAnnotation: (id: string) => void clearAnnotations: () => void + markAnnotationApplied: (id: string) => void + linkAnnotationsToMessage: (messageId: string, annotationIds: string[]) => void // Analysis mode analysisMode: AnalysisMode @@ -133,7 +135,7 @@ export const useEditorStore = create((set, get) => ({ activeFileContent: content, isDirty: false, chatHistory: existing, - annotations: savedAnnotationState?.annotations ?? [], + annotations: savedAnnotationState?.annotations.filter(a => !a.applied) ?? [], analysisMode: savedAnnotationState?.mode ?? 'none' }) scheduleSave(() => { @@ -238,8 +240,13 @@ export const useEditorStore = create((set, get) => ({ annotationsByFile: {}, setAnnotations: (annotations) => { set((s) => { + // Preserve previously applied annotations so chat history links remain valid + const existingApplied = s.activeFilePath + ? (s.annotationsByFile[s.activeFilePath]?.annotations ?? []).filter(a => a.applied) + : [] + const merged = [...existingApplied, ...annotations] const annotationsByFile = s.activeFilePath - ? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations } } + ? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations: merged } } : s.annotationsByFile return { annotations, annotationsByFile } }) @@ -289,6 +296,53 @@ export const useEditorStore = create((set, get) => ({ }) }, + markAnnotationApplied: (id) => { + set((s) => { + if (!s.activeFilePath) return {} + const fileState = s.annotationsByFile[s.activeFilePath] + if (!fileState) return {} + const updatedAll = fileState.annotations.map(a => + a.id === id ? { ...a, applied: true } : a + ) + const annotationsByFile = { + ...s.annotationsByFile, + [s.activeFilePath]: { ...fileState, annotations: updatedAll } + } + const annotations = s.annotations.filter(a => a.id !== id) + return { annotations, annotationsByFile } + }) + scheduleSave(() => { + const st = get() + return { + activeFilePath: st.activeFilePath, + scrollPositions: st.scrollPositions, + chatHistoryByFile: st.chatHistoryByFile, + annotationsByFile: st.annotationsByFile + } + }) + }, + + linkAnnotationsToMessage: (messageId, annotationIds) => { + set((s) => { + const history = s.chatHistory.map(m => + m.id === messageId ? { ...m, annotationIds } : m + ) + const byFile = s.activeFilePath + ? { ...s.chatHistoryByFile, [s.activeFilePath]: history } + : s.chatHistoryByFile + return { chatHistory: history, chatHistoryByFile: byFile } + }) + scheduleSave(() => { + const st = get() + return { + activeFilePath: st.activeFilePath, + scrollPositions: st.scrollPositions, + chatHistoryByFile: st.chatHistoryByFile, + annotationsByFile: st.annotationsByFile + } + }) + }, + analysisMode: 'none', setAnalysisMode: (analysisMode) => { set((s) => { diff --git a/src/renderer/types/editor.ts b/src/renderer/types/editor.ts index 849f55a..9313424 100644 --- a/src/renderer/types/editor.ts +++ b/src/renderer/types/editor.ts @@ -19,6 +19,7 @@ export interface ChatMessage { role: 'user' | 'assistant' content: string attachments?: AttachmentMeta[] // metadata only — stored in history for display + annotationIds?: string[] // IDs of suggestions this message produced } export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique' | 'custom' @@ -31,6 +32,7 @@ export interface TextAnnotation { matchedText: string message: string suggestion?: string + applied?: boolean // true when the suggestion has been applied to the document } export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'critique' diff --git a/src/renderer/utils/annotationParser.ts b/src/renderer/utils/annotationParser.ts index 8e1dd38..629ff34 100644 --- a/src/renderer/utils/annotationParser.ts +++ b/src/renderer/utils/annotationParser.ts @@ -10,6 +10,7 @@ export function parseAnnotationsFromAIResponse( ): TextAnnotation[] { const annotations: TextAnnotation[] = [] let id = 0 + const runId = Date.now() // Match quoted strings — handles "straight", "curly", and 'single' quotes // Minimum 10 chars to avoid matching short words @@ -50,7 +51,7 @@ export function parseAnnotationsFromAIResponse( const message = extractMessage(aiResponse, match.index) annotations.push({ - id: `ai-${id++}`, + id: `ai-${runId}-${id++}`, type, from: docIndex, to: docIndex + quotedText.length,