💄 feedback tab
This commit is contained in:
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { useEditorStore } from '@renderer/store/editorStore'
|
||||
import { MarkdownEditor } from '@renderer/components/Editor/MarkdownEditor'
|
||||
import { ChatPanel } from '@renderer/components/AIChat/ChatPanel'
|
||||
import '@renderer/styles/global.css'
|
||||
|
||||
// Mock window.api so the renderer doesn't crash in a plain browser context
|
||||
@@ -61,8 +62,21 @@ store.setAnnotations([
|
||||
}
|
||||
])
|
||||
|
||||
function DemoApp(): JSX.Element {
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<MarkdownEditor />
|
||||
</div>
|
||||
<div style={{ width: '300px', flexShrink: 0 }}>
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<MarkdownEditor />
|
||||
<DemoApp />
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -42,11 +42,18 @@
|
||||
"build": {
|
||||
"appId": "com.hohoff.editor",
|
||||
"productName": "Hohoff Editor",
|
||||
"icon": "resources/icon.png",
|
||||
"mac": {
|
||||
"target": "dmg"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources/icon.png",
|
||||
"to": "icon.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
resources/icon.png
Normal file
BIN
resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, shell, nativeImage } from 'electron'
|
||||
import { join, resolve } from 'path'
|
||||
import { config } from 'dotenv'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
@@ -9,6 +9,9 @@ config({ path: resolve(process.cwd(), '.env') })
|
||||
config({ path: resolve(process.cwd(), '.env.local'), override: true })
|
||||
|
||||
function createWindow(): void {
|
||||
const icon = nativeImage.createFromPath(
|
||||
join(__dirname, '../../resources/icon.png')
|
||||
)
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
@@ -16,6 +19,7 @@ function createWindow(): void {
|
||||
minHeight: 600,
|
||||
title: 'Hohoff Editor',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
icon,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -45,6 +49,13 @@ function createWindow(): void {
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.hohoff.editor')
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const dockIcon = nativeImage.createFromPath(
|
||||
join(__dirname, '../../resources/icon.png')
|
||||
)
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
@@ -6,6 +6,68 @@
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Tab bar (replaces the old .chat-header) ─────────── */
|
||||
.chat-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.chat-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-tab-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-tab-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px 1px 0 0;
|
||||
}
|
||||
|
||||
.chat-tab-badge {
|
||||
background: var(--accent);
|
||||
color: #1a1208;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Push Clear button to the far right inside the tab bar */
|
||||
.chat-tabs .chat-clear-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import { ChatMessageItem } from './ChatMessageItem'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { FeedbackPanel } from '../Feedback/FeedbackPanel'
|
||||
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||
import './Chat.css'
|
||||
|
||||
type TabId = 'chat' | 'feedback'
|
||||
|
||||
export function ChatPanel(): JSX.Element {
|
||||
const {
|
||||
chatHistory,
|
||||
@@ -13,6 +16,7 @@ export function ChatPanel(): JSX.Element {
|
||||
activeFileContent,
|
||||
activeFilePath,
|
||||
analysisMode,
|
||||
annotations,
|
||||
addUserMessage,
|
||||
startAssistantMessage,
|
||||
appendToLastAssistantMessage,
|
||||
@@ -22,7 +26,19 @@ export function ChatPanel(): JSX.Element {
|
||||
clearChat
|
||||
} = useEditorStore()
|
||||
|
||||
const [tab, setTab] = useState<TabId>('chat')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const prevAnnotationCountRef = useRef(annotations.length)
|
||||
|
||||
// Auto-switch to Feedback tab the first time annotations appear (0 → >0)
|
||||
useEffect(() => {
|
||||
const prev = prevAnnotationCountRef.current
|
||||
const curr = annotations.length
|
||||
if (prev === 0 && curr > 0) {
|
||||
setTab('feedback')
|
||||
}
|
||||
prevAnnotationCountRef.current = curr
|
||||
}, [annotations])
|
||||
|
||||
const sendMessage = async (text: string): Promise<void> => {
|
||||
if (!activeFilePath || isAILoading) return
|
||||
@@ -53,9 +69,9 @@ export function ChatPanel(): JSX.Element {
|
||||
const currentHistory = useEditorStore.getState().chatHistory
|
||||
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
||||
const annotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
|
||||
if (annotations.length > 0) {
|
||||
setAnnotations(annotations)
|
||||
const parsed = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
|
||||
if (parsed.length > 0) {
|
||||
setAnnotations(parsed)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -78,15 +94,37 @@ export function ChatPanel(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-header">
|
||||
<span>AI Editor</span>
|
||||
{chatHistory.length > 0 && (
|
||||
{/* ── Tab bar ── */}
|
||||
<div className="chat-tabs">
|
||||
<button
|
||||
className={`chat-tab${tab === 'chat' ? ' chat-tab-active' : ''}`}
|
||||
onClick={() => setTab('chat')}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
className={`chat-tab${tab === 'feedback' ? ' chat-tab-active' : ''}`}
|
||||
onClick={() => setTab('feedback')}
|
||||
>
|
||||
Feedback
|
||||
{annotations.length > 0 && (
|
||||
<span className="chat-tab-badge">{annotations.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Clear button floated right, only visible in Chat tab */}
|
||||
{tab === 'chat' && chatHistory.length > 0 && (
|
||||
<button className="chat-clear-btn" onClick={clearChat} title="Clear conversation">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Panel content ── */}
|
||||
{tab === 'feedback' ? (
|
||||
<FeedbackPanel />
|
||||
) : (
|
||||
<>
|
||||
<div className="chat-messages" ref={scrollRef}>
|
||||
{!hasFile && (
|
||||
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
|
||||
@@ -112,6 +150,8 @@ export function ChatPanel(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<ChatInput onSend={sendMessage} disabled={!hasFile || isAILoading} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ const rawAnnotationsField = StateField.define<TextAnnotation[]>({
|
||||
})
|
||||
|
||||
// Per-session cache: annotation id → { text, suggestion extracted from blockquote }
|
||||
const tooltipAnalysisCache = new Map<string, { text: string; suggestion: string | null }>()
|
||||
export const tooltipAnalysisCache = new Map<string, { text: string; suggestion: string | null }>()
|
||||
|
||||
// Extract the first blockquote from a markdown string — used as the applicable rewrite
|
||||
function extractBlockquote(markdown: string): string | null {
|
||||
const lines = markdown.split('\n')
|
||||
function extractBlockquote(md: string): string | null {
|
||||
const lines = md.split('\n')
|
||||
const bqLines: string[] = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('> ')) bqLines.push(line.slice(2))
|
||||
@@ -39,12 +39,101 @@ function extractBlockquote(markdown: string): string | null {
|
||||
return text || null
|
||||
}
|
||||
|
||||
// Shared analysis function — used by both the hover tooltip and FeedbackPanel.
|
||||
// Returns a cancel function; call it to stop receiving onUpdate callbacks.
|
||||
export function analyseAnnotation(
|
||||
ann: TextAnnotation,
|
||||
onUpdate: (text: string, streaming: boolean, suggestion: string | null) => void
|
||||
): () => void {
|
||||
const cached = tooltipAnalysisCache.get(ann.id)
|
||||
if (cached) {
|
||||
onUpdate(cached.text, false, cached.suggestion)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const { activeFilePath, activeFileContent } = useEditorStore.getState()
|
||||
const typeName = ann.type.replace(/_/g, ' ')
|
||||
let accumulated = ''
|
||||
|
||||
const api = (window as unknown as { api?: { streamAIMessage: (p: unknown, cb: (c: string) => void) => Promise<void> } }).api
|
||||
if (api?.streamAIMessage) {
|
||||
api.streamAIMessage(
|
||||
{
|
||||
mode: 'chat',
|
||||
documentContent: activeFileContent,
|
||||
documentPath: activeFilePath ?? '',
|
||||
conversationHistory: [],
|
||||
userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain the specific issue, then provide a direct rewrite in a markdown blockquote like this:\n\n> Rewritten passage here.\n\nBe specific to this exact text—no generic advice.`
|
||||
},
|
||||
(chunk: string) => {
|
||||
if (cancelled) return
|
||||
accumulated += chunk
|
||||
onUpdate(accumulated, true, null)
|
||||
}
|
||||
).then(() => {
|
||||
if (cancelled) return
|
||||
const text = accumulated || ann.message
|
||||
const suggestion = extractBlockquote(text) ?? ann.suggestion ?? null
|
||||
tooltipAnalysisCache.set(ann.id, { text, suggestion })
|
||||
onUpdate(text, false, suggestion)
|
||||
}).catch(() => {
|
||||
if (!cancelled) onUpdate(ann.message, false, ann.suggestion ?? null)
|
||||
})
|
||||
} else {
|
||||
const suggestion = ann.suggestion ?? null
|
||||
tooltipAnalysisCache.set(ann.id, { text: ann.message, suggestion })
|
||||
onUpdate(ann.message, false, suggestion)
|
||||
}
|
||||
|
||||
return () => { cancelled = true }
|
||||
}
|
||||
|
||||
// Exported module-level reference so FeedbackPanel can access the live EditorView
|
||||
// without prop-drilling. Set in the mount effect, nulled on cleanup.
|
||||
export let currentEditorView: EditorView | null = null
|
||||
|
||||
// Scroll the editor to an annotation and place the cursor there
|
||||
export function scrollToAnnotation(ann: TextAnnotation): void {
|
||||
const view = currentEditorView
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
selection: { anchor: ann.from },
|
||||
effects: EditorView.scrollIntoView(ann.from, { y: 'center' })
|
||||
})
|
||||
}
|
||||
|
||||
// Apply a suggestion to the document and remove that annotation
|
||||
export function applyAnnotation(ann: TextAnnotation, suggestion: string): void {
|
||||
const view = currentEditorView
|
||||
if (!view) return
|
||||
const { annotations: anns, setAnnotations } = 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.
|
||||
const changeSet = view.state.changes(changeSpec)
|
||||
const remaining = anns
|
||||
.filter(a => a.id !== ann.id)
|
||||
.map(a => ({ ...a, from: changeSet.mapPos(a.from), to: changeSet.mapPos(a.to) }))
|
||||
// Combine text replacement + annotation update in one transaction
|
||||
// so CM history treats them as a single undoable unit.
|
||||
view.dispatch({
|
||||
changes: changeSpec,
|
||||
effects: setAnnotationsEffect.of(remaining)
|
||||
})
|
||||
setAnnotations(remaining)
|
||||
tooltipAnalysisCache.delete(ann.id)
|
||||
}
|
||||
|
||||
// Hover tooltip — lazily streams a specific AI analysis for the hovered passage
|
||||
const annotationHoverTooltip = hoverTooltip(
|
||||
(view, pos) => {
|
||||
const annotations = view.state.field(rawAnnotationsField)
|
||||
const ann = annotations.find(a => pos >= a.from && pos <= a.to)
|
||||
if (!ann) return null
|
||||
const found = annotations.find(a => pos >= a.from && pos <= a.to)
|
||||
if (!found) return null
|
||||
// Capture in a new const so TypeScript preserves the non-undefined type
|
||||
// across the nested create() closure without requiring non-null assertions.
|
||||
const ann: TextAnnotation = found
|
||||
|
||||
return {
|
||||
pos,
|
||||
@@ -66,8 +155,6 @@ const annotationHoverTooltip = hoverTooltip(
|
||||
body.className = 'annotation-tooltip-body'
|
||||
dom.appendChild(body)
|
||||
|
||||
let destroyed = false
|
||||
|
||||
function showText(text: string, streaming = false): void {
|
||||
body.classList.remove('annotation-tooltip-loading')
|
||||
const raw = marked.parse(streaming ? text + ' ▋' : text) as string
|
||||
@@ -83,74 +170,22 @@ const annotationHoverTooltip = hoverTooltip(
|
||||
btn.className = 'annotation-tooltip-apply'
|
||||
btn.textContent = 'Apply suggestion'
|
||||
btn.addEventListener('click', () => {
|
||||
const { annotations: anns, setAnnotations } = 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.
|
||||
const changeSet = view.state.changes(changeSpec)
|
||||
const remaining = anns
|
||||
.filter(a => a.id !== ann.id)
|
||||
.map(a => ({ ...a, from: changeSet.mapPos(a.from), to: changeSet.mapPos(a.to) }))
|
||||
// Combine text replacement + annotation update in one transaction
|
||||
// so CM history treats them as a single undoable unit (Cmd+Z
|
||||
// restores both the original text and the highlight together).
|
||||
view.dispatch({
|
||||
changes: changeSpec,
|
||||
effects: setAnnotationsEffect.of(remaining)
|
||||
})
|
||||
setAnnotations(remaining)
|
||||
tooltipAnalysisCache.delete(ann.id)
|
||||
applyAnnotation(ann, suggestion)
|
||||
})
|
||||
dom.appendChild(btn)
|
||||
}
|
||||
|
||||
const cached = tooltipAnalysisCache.get(ann.id)
|
||||
if (cached) {
|
||||
showText(cached.text)
|
||||
if (cached.suggestion) showApplyButton(cached.suggestion)
|
||||
} else {
|
||||
body.classList.add('annotation-tooltip-loading')
|
||||
body.innerHTML = 'Analysing…'
|
||||
|
||||
const { activeFilePath, activeFileContent } = useEditorStore.getState()
|
||||
const typeName = ann.type.replace(/_/g, ' ')
|
||||
let accumulated = ''
|
||||
|
||||
const api = (window as unknown as { api?: { streamAIMessage: (p: unknown, cb: (c: string) => void) => Promise<void> } }).api
|
||||
if (api?.streamAIMessage) {
|
||||
api.streamAIMessage(
|
||||
{
|
||||
mode: 'chat',
|
||||
documentContent: activeFileContent,
|
||||
documentPath: activeFilePath ?? '',
|
||||
conversationHistory: [],
|
||||
userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain the specific issue, then provide a direct rewrite in a markdown blockquote like this:\n\n> Rewritten passage here.\n\nBe specific to this exact text—no generic advice.`
|
||||
},
|
||||
(chunk: string) => {
|
||||
if (destroyed) return
|
||||
accumulated += chunk
|
||||
showText(accumulated, true)
|
||||
const cancelAnalysis = analyseAnnotation(ann, (text, streaming, suggestion) => {
|
||||
showText(text, streaming)
|
||||
if (!streaming && suggestion && !dom.querySelector('.annotation-tooltip-apply')) {
|
||||
showApplyButton(suggestion)
|
||||
}
|
||||
).then(() => {
|
||||
if (destroyed) return
|
||||
const text = accumulated || ann.message
|
||||
const suggestion = extractBlockquote(text) ?? ann.suggestion ?? null
|
||||
tooltipAnalysisCache.set(ann.id, { text, suggestion })
|
||||
showText(text)
|
||||
if (suggestion) showApplyButton(suggestion)
|
||||
}).catch(() => {
|
||||
if (!destroyed) showText(ann.message)
|
||||
})
|
||||
} else {
|
||||
// Fallback: use stored message and suggestion
|
||||
const suggestion = ann.suggestion ?? null
|
||||
tooltipAnalysisCache.set(ann.id, { text: ann.message, suggestion })
|
||||
showText(ann.message)
|
||||
if (suggestion) showApplyButton(suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
return { dom, destroy() { destroyed = true } }
|
||||
return { dom, destroy() { cancelAnalysis() } }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -315,6 +350,8 @@ export function MarkdownEditor(): JSX.Element {
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
currentEditorView = view
|
||||
|
||||
// Expose view + undo for dev-mode testing (stripped in production)
|
||||
if (import.meta.env.DEV) {
|
||||
const w = window as unknown as Record<string, unknown>
|
||||
@@ -328,6 +365,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
currentEditorView = null
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
205
src/renderer/components/Feedback/FeedbackPanel.css
Normal file
205
src/renderer/components/Feedback/FeedbackPanel.css
Normal file
@@ -0,0 +1,205 @@
|
||||
.fb-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1; /* fill remaining space after the tab bar */
|
||||
min-height: 0; /* allow the flex child to shrink below its content size */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────── */
|
||||
.fb-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fb-toolbar-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 9px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.fb-toolbar-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fb-toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fb-toolbar-btn--clear:hover:not(:disabled) {
|
||||
color: #e07070;
|
||||
border-color: rgba(200, 60, 60, 0.5);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────── */
|
||||
.fb-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
line-height: 1.65;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fb-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Card list ───────────────────────────────────────── */
|
||||
.fb-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Individual card ─────────────────────────────────── */
|
||||
.fb-card {
|
||||
flex-shrink: 0; /* never compress — let the list scroll instead */
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--message-bg);
|
||||
}
|
||||
|
||||
.fb-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.fb-card-header:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Coloured type badge */
|
||||
.fb-card-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--badge-color, var(--accent));
|
||||
/* Soft tint background using the badge colour */
|
||||
background: color-mix(in srgb, var(--badge-color, var(--accent)) 15%, transparent);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Passage excerpt — wraps so the full text is always visible */
|
||||
.fb-card-excerpt {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Analyse button (idle state) ─────────────────────── */
|
||||
.fb-card-analyse-btn {
|
||||
display: block;
|
||||
width: calc(100% - 20px);
|
||||
margin: 0 10px 10px;
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.fb-card-analyse-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Loading dots ────────────────────────────────────── */
|
||||
.fb-card-loading {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 10px 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fb-card-loading span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fb-card-loading span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.fb-card-loading span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* ── Analysis body (markdown) ────────────────────────── */
|
||||
.fb-card-analysis {
|
||||
padding: 4px 10px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Shrink Chat.css markdown styles for the smaller card context */
|
||||
.fb-card-analysis p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.fb-card-analysis p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fb-card-analysis blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
margin: 6px 0;
|
||||
padding: 2px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Apply suggestion button ─────────────────────────── */
|
||||
.fb-card-apply {
|
||||
display: block;
|
||||
width: calc(100% - 20px);
|
||||
margin: 4px 10px 10px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #1a1208;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fb-card-apply:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
183
src/renderer/components/Feedback/FeedbackPanel.tsx
Normal file
183
src/renderer/components/Feedback/FeedbackPanel.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { TextAnnotation } from '../../types/editor'
|
||||
import {
|
||||
tooltipAnalysisCache,
|
||||
analyseAnnotation,
|
||||
scrollToAnnotation,
|
||||
applyAnnotation
|
||||
} from '../Editor/MarkdownEditor'
|
||||
import './FeedbackPanel.css'
|
||||
|
||||
type AnalysisState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'streaming'; text: string }
|
||||
| { status: 'done'; text: string; suggestion: string | null }
|
||||
|
||||
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)'
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string, streaming: boolean): string {
|
||||
const raw = marked.parse(streaming ? text + ' ▋' : text) as string
|
||||
return DOMPurify.sanitize(raw)
|
||||
}
|
||||
|
||||
interface FeedbackCardProps {
|
||||
ann: TextAnnotation
|
||||
autoAnalyse: boolean
|
||||
}
|
||||
|
||||
function FeedbackCard({ ann, autoAnalyse }: FeedbackCardProps): JSX.Element {
|
||||
const [state, setState] = useState<AnalysisState>(() => {
|
||||
const cached = tooltipAnalysisCache.get(ann.id)
|
||||
if (cached) return { status: 'done', text: cached.text, suggestion: cached.suggestion }
|
||||
return { status: 'idle' }
|
||||
})
|
||||
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
function startAnalysis(): void {
|
||||
// Prevent double-start
|
||||
if (state.status === 'streaming') return
|
||||
cleanupRef.current?.()
|
||||
setState({ status: 'streaming', text: '' })
|
||||
cleanupRef.current = analyseAnnotation(ann, (text, streaming, suggestion) => {
|
||||
if (streaming) {
|
||||
setState({ status: 'streaming', text })
|
||||
} else {
|
||||
setState({ status: 'done', text, suggestion })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger analysis when parent requests "Analyse all"
|
||||
useEffect(() => {
|
||||
if (autoAnalyse && state.status === 'idle') {
|
||||
startAnalysis()
|
||||
}
|
||||
}, [autoAnalyse]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { cleanupRef.current?.() }
|
||||
}, [])
|
||||
|
||||
const typeName = ann.type.replace(/_/g, ' ')
|
||||
const isSpinning = state.status === 'streaming' && state.text === ''
|
||||
const hasText = (state.status === 'streaming' || state.status === 'done') && state.text !== ''
|
||||
const suggestion = state.status === 'done' ? state.suggestion : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fb-card fb-card-${ann.type}`}
|
||||
style={{ '--badge-color': badgeColor(ann.type) } as React.CSSProperties}
|
||||
>
|
||||
{/* Header — click to jump to passage in editor */}
|
||||
<div className="fb-card-header" onClick={() => scrollToAnnotation(ann)} title="Jump to passage">
|
||||
<span className="fb-card-badge">{typeName}</span>
|
||||
<span className="fb-card-excerpt">"{ann.matchedText}"</span>
|
||||
</div>
|
||||
|
||||
{/* Idle: show Analyse button */}
|
||||
{state.status === 'idle' && (
|
||||
<button className="fb-card-analyse-btn" onClick={startAnalysis}>
|
||||
Analyse
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Streaming with no text yet: show bouncing dots */}
|
||||
{isSpinning && (
|
||||
<div className="fb-card-loading">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming or done with text: show markdown body */}
|
||||
{hasText && (
|
||||
<div
|
||||
className="fb-card-analysis chat-message-markdown"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdown(state.text, state.status === 'streaming')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Done with a suggestion: show Apply button */}
|
||||
{suggestion != null && (
|
||||
<button
|
||||
className="fb-card-apply"
|
||||
onClick={() => applyAnnotation(ann, suggestion)}
|
||||
>
|
||||
Apply suggestion
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeedbackPanel(): JSX.Element {
|
||||
const { annotations, setAnnotations } = useEditorStore()
|
||||
const [analyseAll, setAnalyseAll] = useState(false)
|
||||
|
||||
// Reset "Analyse all" whenever the annotation set changes (new critique run),
|
||||
// so auto-analysis doesn't carry over to fresh results unexpectedly.
|
||||
const prevAnnotationsRef = useRef(annotations)
|
||||
useEffect(() => {
|
||||
if (prevAnnotationsRef.current !== annotations) {
|
||||
setAnalyseAll(false)
|
||||
prevAnnotationsRef.current = annotations
|
||||
}
|
||||
}, [annotations])
|
||||
|
||||
function handleClearAll(): void {
|
||||
setAnnotations([])
|
||||
tooltipAnalysisCache.clear()
|
||||
}
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div className="fb-panel">
|
||||
<div className="fb-empty">
|
||||
<p>No feedback yet.</p>
|
||||
<p>Run a critique from the toolbar to highlight issues in your text.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fb-panel">
|
||||
<div className="fb-toolbar">
|
||||
<button
|
||||
className="fb-toolbar-btn"
|
||||
onClick={() => setAnalyseAll(true)}
|
||||
disabled={analyseAll}
|
||||
title="Run AI analysis on all highlighted passages"
|
||||
>
|
||||
Analyse all
|
||||
</button>
|
||||
<button
|
||||
className="fb-toolbar-btn fb-toolbar-btn--clear"
|
||||
onClick={handleClearAll}
|
||||
title="Remove all highlights"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fb-list">
|
||||
{annotations.map(ann => (
|
||||
<FeedbackCard key={ann.id} ann={ann} autoAnalyse={analyseAll} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": ["src/renderer/*"]
|
||||
}
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user