✨ attached suggestions to chats
This commit is contained in:
@@ -475,3 +475,69 @@
|
|||||||
.chat-send-btn:not(:disabled):hover {
|
.chat-send-btn:not(:disabled):hover {
|
||||||
opacity: 0.85;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import type { ChatMessage } from '../../types/editor'
|
import type { ChatMessage, TextAnnotation } from '../../types/editor'
|
||||||
|
|
||||||
marked.setOptions({ breaks: true })
|
marked.setOptions({ breaks: true })
|
||||||
|
|
||||||
@@ -11,11 +11,22 @@ function attachmentIcon(mimeType: string): string {
|
|||||||
return '📝'
|
return '📝'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
function badgeColor(type: TextAnnotation['type']): string {
|
||||||
message: ChatMessage
|
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(() => {
|
const html = useMemo(() => {
|
||||||
if (message.role !== 'assistant' || !message.content) return null
|
if (message.role !== 'assistant' || !message.content) return null
|
||||||
const raw = marked.parse(message.content) as string
|
const raw = marked.parse(message.content) as string
|
||||||
@@ -47,6 +58,34 @@ export function ChatMessageItem({ message }: Props): JSX.Element {
|
|||||||
message.content || <span className="chat-streaming-cursor">▍</span>
|
message.content || <span className="chat-streaming-cursor">▍</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{linkedAnnotations && linkedAnnotations.length > 0 && (
|
||||||
|
<div className="chat-message-suggestions">
|
||||||
|
<span className="chat-suggestions-label">
|
||||||
|
{linkedAnnotations.length} suggestion{linkedAnnotations.length !== 1 ? 's' : ''} created
|
||||||
|
</span>
|
||||||
|
<div className="chat-suggestion-list">
|
||||||
|
{linkedAnnotations.map(ann => (
|
||||||
|
<div key={ann.id} className="chat-suggestion-row">
|
||||||
|
<span
|
||||||
|
className="chat-suggestion-badge"
|
||||||
|
style={{ backgroundColor: badgeColor(ann.type) }}
|
||||||
|
>
|
||||||
|
{ann.type.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="chat-suggestion-excerpt">
|
||||||
|
"{ann.matchedText.length > 40
|
||||||
|
? ann.matchedText.slice(0, 38) + '…'
|
||||||
|
: ann.matchedText}"
|
||||||
|
</span>
|
||||||
|
{ann.applied && (
|
||||||
|
<span className="chat-suggestion-applied">Applied</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ChatMessageItem } from './ChatMessageItem'
|
|||||||
import { ChatInput } from './ChatInput'
|
import { ChatInput } from './ChatInput'
|
||||||
import { FeedbackPanel } from '../Feedback/FeedbackPanel'
|
import { FeedbackPanel } from '../Feedback/FeedbackPanel'
|
||||||
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||||
import type { Attachment } from '../../types/editor'
|
import type { Attachment, TextAnnotation } from '../../types/editor'
|
||||||
import './Chat.css'
|
import './Chat.css'
|
||||||
|
|
||||||
type TabId = 'chat' | 'feedback'
|
type TabId = 'chat' | 'feedback'
|
||||||
@@ -18,12 +18,14 @@ export function ChatPanel(): JSX.Element {
|
|||||||
activeFilePath,
|
activeFilePath,
|
||||||
analysisMode,
|
analysisMode,
|
||||||
annotations,
|
annotations,
|
||||||
|
annotationsByFile,
|
||||||
addUserMessage,
|
addUserMessage,
|
||||||
startAssistantMessage,
|
startAssistantMessage,
|
||||||
appendToLastAssistantMessage,
|
appendToLastAssistantMessage,
|
||||||
setAILoading,
|
setAILoading,
|
||||||
setAIError,
|
setAIError,
|
||||||
setAnnotations,
|
setAnnotations,
|
||||||
|
linkAnnotationsToMessage,
|
||||||
clearChat
|
clearChat
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export function ChatPanel(): JSX.Element {
|
|||||||
const parsed = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent, overrideType)
|
const parsed = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent, overrideType)
|
||||||
if (parsed.length > 0) {
|
if (parsed.length > 0) {
|
||||||
setAnnotations(parsed)
|
setAnnotations(parsed)
|
||||||
|
linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -97,6 +100,8 @@ export function ChatPanel(): JSX.Element {
|
|||||||
}, [chatHistory])
|
}, [chatHistory])
|
||||||
|
|
||||||
const hasFile = Boolean(activeFilePath)
|
const hasFile = Boolean(activeFilePath)
|
||||||
|
const allAnnotationsForFile: TextAnnotation[] =
|
||||||
|
(activeFilePath ? annotationsByFile[activeFilePath]?.annotations : undefined) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel">
|
<div className="chat-panel">
|
||||||
@@ -148,9 +153,18 @@ export function ChatPanel(): JSX.Element {
|
|||||||
Ask anything about the current chapter — passive voice, plot, character, style...
|
Ask anything about the current chapter — passive voice, plot, character, style...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{chatHistory.map((msg) => (
|
{chatHistory.map((msg) => {
|
||||||
<ChatMessageItem key={msg.id} message={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 === '' && (
|
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
|
||||||
<div className="chat-typing">
|
<div className="chat-typing">
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -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 {
|
export function applyAnnotation(ann: TextAnnotation, suggestion: string): void {
|
||||||
const view = currentEditorView
|
const view = currentEditorView
|
||||||
if (!view) return
|
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 }
|
const changeSpec = { from: ann.from, to: ann.to, insert: suggestion }
|
||||||
// Map surviving annotation positions through the text change so
|
// Map surviving annotation positions through the text change so
|
||||||
// their from/to reflect the new document offsets.
|
// their from/to reflect the new document offsets.
|
||||||
@@ -173,7 +173,9 @@ export function applyAnnotation(ann: TextAnnotation, suggestion: string): void {
|
|||||||
changes: changeSpec,
|
changes: changeSpec,
|
||||||
effects: setAnnotationsEffect.of(remaining)
|
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)
|
tooltipAnalysisCache.delete(ann.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface EditorState {
|
|||||||
setAnnotations: (annotations: TextAnnotation[]) => void
|
setAnnotations: (annotations: TextAnnotation[]) => void
|
||||||
removeAnnotation: (id: string) => void
|
removeAnnotation: (id: string) => void
|
||||||
clearAnnotations: () => void
|
clearAnnotations: () => void
|
||||||
|
markAnnotationApplied: (id: string) => void
|
||||||
|
linkAnnotationsToMessage: (messageId: string, annotationIds: string[]) => void
|
||||||
|
|
||||||
// Analysis mode
|
// Analysis mode
|
||||||
analysisMode: AnalysisMode
|
analysisMode: AnalysisMode
|
||||||
@@ -133,7 +135,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
activeFileContent: content,
|
activeFileContent: content,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
chatHistory: existing,
|
chatHistory: existing,
|
||||||
annotations: savedAnnotationState?.annotations ?? [],
|
annotations: savedAnnotationState?.annotations.filter(a => !a.applied) ?? [],
|
||||||
analysisMode: savedAnnotationState?.mode ?? 'none'
|
analysisMode: savedAnnotationState?.mode ?? 'none'
|
||||||
})
|
})
|
||||||
scheduleSave(() => {
|
scheduleSave(() => {
|
||||||
@@ -238,8 +240,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
annotationsByFile: {},
|
annotationsByFile: {},
|
||||||
setAnnotations: (annotations) => {
|
setAnnotations: (annotations) => {
|
||||||
set((s) => {
|
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
|
const annotationsByFile = s.activeFilePath
|
||||||
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations } }
|
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations: merged } }
|
||||||
: s.annotationsByFile
|
: s.annotationsByFile
|
||||||
return { annotations, annotationsByFile }
|
return { annotations, annotationsByFile }
|
||||||
})
|
})
|
||||||
@@ -289,6 +296,53 @@ export const useEditorStore = create<EditorState>((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',
|
analysisMode: 'none',
|
||||||
setAnalysisMode: (analysisMode) => {
|
setAnalysisMode: (analysisMode) => {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
attachments?: AttachmentMeta[] // metadata only — stored in history for display
|
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'
|
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique' | 'custom'
|
||||||
@@ -31,6 +32,7 @@ export interface TextAnnotation {
|
|||||||
matchedText: string
|
matchedText: string
|
||||||
message: string
|
message: string
|
||||||
suggestion?: string
|
suggestion?: string
|
||||||
|
applied?: boolean // true when the suggestion has been applied to the document
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'critique'
|
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'critique'
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function parseAnnotationsFromAIResponse(
|
|||||||
): TextAnnotation[] {
|
): TextAnnotation[] {
|
||||||
const annotations: TextAnnotation[] = []
|
const annotations: TextAnnotation[] = []
|
||||||
let id = 0
|
let id = 0
|
||||||
|
const runId = Date.now()
|
||||||
|
|
||||||
// Match quoted strings — handles "straight", "curly", and 'single' quotes
|
// Match quoted strings — handles "straight", "curly", and 'single' quotes
|
||||||
// Minimum 10 chars to avoid matching short words
|
// Minimum 10 chars to avoid matching short words
|
||||||
@@ -50,7 +51,7 @@ export function parseAnnotationsFromAIResponse(
|
|||||||
const message = extractMessage(aiResponse, match.index)
|
const message = extractMessage(aiResponse, match.index)
|
||||||
|
|
||||||
annotations.push({
|
annotations.push({
|
||||||
id: `ai-${id++}`,
|
id: `ai-${runId}-${id++}`,
|
||||||
type,
|
type,
|
||||||
from: docIndex,
|
from: docIndex,
|
||||||
to: docIndex + quotedText.length,
|
to: docIndex + quotedText.length,
|
||||||
|
|||||||
Reference in New Issue
Block a user