💄 feedback tab

This commit is contained in:
2026-02-22 12:49:54 +10:00
parent e7205dd2c2
commit 16704c277f
11 changed files with 663 additions and 102 deletions

View 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;
}

View 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>
)
}