✨ attached suggestions to chats
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || <span className="chat-streaming-cursor">▍</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="chat-panel">
|
||||
@@ -148,9 +153,18 @@ export function ChatPanel(): JSX.Element {
|
||||
Ask anything about the current chapter — passive voice, plot, character, style...
|
||||
</p>
|
||||
)}
|
||||
{chatHistory.map((msg) => (
|
||||
<ChatMessageItem key={msg.id} message={msg} />
|
||||
))}
|
||||
{chatHistory.map((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 === '' && (
|
||||
<div className="chat-typing">
|
||||
<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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user