feedback archives

This commit is contained in:
2026-03-01 15:43:00 +10:00
parent 70f8c00e5e
commit 0e584c5e03
4 changed files with 338 additions and 38 deletions

View File

@@ -252,3 +252,159 @@
.fb-card-apply:hover { .fb-card-apply:hover {
opacity: 0.85; opacity: 0.85;
} }
/* ── Archive section ─────────────────────────────────── */
.fb-archive {
flex-shrink: 0;
border-top: 1px solid var(--border);
}
.fb-archive-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 12px;
cursor: pointer;
list-style: none;
user-select: none;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
transition: color 0.15s;
}
.fb-archive-header::-webkit-details-marker { display: none; }
.fb-archive-header:hover {
color: var(--text-secondary);
}
.fb-archive-title {
display: flex;
align-items: center;
gap: 6px;
}
.fb-archive-count {
background: var(--border);
color: var(--text-muted);
border-radius: 10px;
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
letter-spacing: 0;
}
.fb-archive-clear-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 2px 7px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.fb-archive-clear-btn:hover {
color: #e07070;
border-color: rgba(200, 60, 60, 0.5);
}
.fb-archive-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 12px 10px;
max-height: 260px;
overflow-y: auto;
}
/* ── Archive card ────────────────────────────────────── */
.fb-archive-card {
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: 5px;
padding: 7px 10px;
background: var(--message-bg);
opacity: 0.7;
display: flex;
flex-direction: column;
gap: 4px;
}
.fb-archive-card:hover {
opacity: 1;
}
.fb-archive-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fb-archive-card-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* "applied" / "dismissed" status tag */
.fb-archive-status {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 3px;
padding: 1px 5px;
white-space: nowrap;
align-self: flex-start;
}
.fb-archive-status--applied {
color: rgba(80, 200, 120, 0.85);
border: 1px solid rgba(80, 200, 120, 0.3);
}
.fb-archive-status--dismissed {
color: rgba(160, 160, 160, 0.75);
border: 1px solid rgba(160, 160, 160, 0.25);
}
.fb-archive-card-dismiss {
background: none;
border: none;
color: var(--text-muted);
font-size: 15px;
line-height: 1;
padding: 0 2px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
}
.fb-archive-card:hover .fb-archive-card-dismiss {
opacity: 1;
}
.fb-archive-card-dismiss:hover {
color: var(--text-primary);
}
.fb-archive-card-excerpt {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
line-height: 1.5;
}
.fb-archive-card-suggestion {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
}

View File

@@ -145,10 +145,56 @@ function FeedbackCard({ ann, autoAnalyse, onDismiss }: FeedbackCardProps): JSX.E
) )
} }
interface ArchiveCardProps {
ann: TextAnnotation
onRemove?: () => void
}
function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element {
const typeName = ann.type.replace(/_/g, ' ')
const status = ann.applied ? 'applied' : 'dismissed'
return (
<div
className={`fb-archive-card fb-archive-card--${status}`}
style={{ '--badge-color': badgeColor(ann.type) } as React.CSSProperties}
>
<div className="fb-archive-card-header">
<div className="fb-archive-card-badges">
<span className="fb-card-badge">{typeName}</span>
<span className={`fb-archive-status fb-archive-status--${status}`}>{status}</span>
</div>
{onRemove && (
<button className="fb-archive-card-dismiss" onClick={onRemove} title="Remove from archive">
×
</button>
)}
</div>
<span className="fb-archive-card-excerpt">"{ann.matchedText}"</span>
{ann.suggestion && (
<span className="fb-archive-card-suggestion"> {ann.suggestion}</span>
)}
</div>
)
}
export function FeedbackPanel(): JSX.Element { export function FeedbackPanel(): JSX.Element {
const { annotations, clearAnnotations, removeAnnotation } = useEditorStore() const {
annotations,
annotationsByFile,
activeFilePath,
clearAnnotations,
removeAnnotation,
clearArchivedAnnotations,
removeArchivedAnnotation,
} = useEditorStore()
const [analyseAll, setAnalyseAll] = useState(false) const [analyseAll, setAnalyseAll] = useState(false)
const archivedAnnotations = activeFilePath
? (annotationsByFile[activeFilePath]?.annotations ?? []).filter(a => a.applied || a.dismissed)
: []
const dismissedCount = archivedAnnotations.filter(a => a.dismissed).length
// Reset "Analyse all" whenever the annotation set changes (new critique run), // Reset "Analyse all" whenever the annotation set changes (new critique run),
// so auto-analysis doesn't carry over to fresh results unexpectedly. // so auto-analysis doesn't carry over to fresh results unexpectedly.
const prevAnnotationsRef = useRef(annotations) const prevAnnotationsRef = useRef(annotations)
@@ -164,7 +210,10 @@ export function FeedbackPanel(): JSX.Element {
tooltipAnalysisCache.clear() tooltipAnalysisCache.clear()
} }
if (annotations.length === 0) { const hasActive = annotations.length > 0
const hasArchive = archivedAnnotations.length > 0
if (!hasActive && !hasArchive) {
return ( return (
<div className="fb-panel"> <div className="fb-panel">
<div className="fb-empty"> <div className="fb-empty">
@@ -177,34 +226,67 @@ export function FeedbackPanel(): JSX.Element {
return ( return (
<div className="fb-panel"> <div className="fb-panel">
<div className="fb-toolbar"> {hasActive && (
<button <>
className="fb-toolbar-btn" <div className="fb-toolbar">
onClick={() => setAnalyseAll(true)} <button
disabled={analyseAll} className="fb-toolbar-btn"
title="Run AI analysis on all highlighted passages" onClick={() => setAnalyseAll(true)}
> disabled={analyseAll}
Analyse all title="Run AI analysis on all highlighted passages"
</button> >
<button Analyse all
className="fb-toolbar-btn fb-toolbar-btn--clear" </button>
onClick={handleClearAll} <button
title="Remove all highlights" className="fb-toolbar-btn fb-toolbar-btn--clear"
> onClick={handleClearAll}
Clear all title="Archive all highlights"
</button> >
</div> Clear all
</button>
</div>
<div className="fb-list"> <div className="fb-list">
{annotations.map(ann => ( {annotations.map(ann => (
<FeedbackCard <FeedbackCard
key={ann.id} key={ann.id}
ann={ann} ann={ann}
autoAnalyse={analyseAll} autoAnalyse={analyseAll}
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }} onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
/> />
))} ))}
</div> </div>
</>
)}
{hasArchive && (
<details className="fb-archive">
<summary className="fb-archive-header">
<span className="fb-archive-title">
Archive
<span className="fb-archive-count">{archivedAnnotations.length}</span>
</span>
{dismissedCount > 0 && (
<button
className="fb-archive-clear-btn"
onClick={(e) => { e.preventDefault(); clearArchivedAnnotations() }}
title="Permanently remove all dismissed items"
>
Clear dismissed
</button>
)}
</summary>
<div className="fb-archive-list">
{archivedAnnotations.map(ann => (
<ArchiveCard
key={ann.id}
ann={ann}
onRemove={ann.dismissed ? () => removeArchivedAnnotation(ann.id) : undefined}
/>
))}
</div>
</details>
)}
</div> </div>
) )
} }

View File

@@ -41,6 +41,8 @@ interface EditorState {
clearAnnotations: () => void clearAnnotations: () => void
markAnnotationApplied: (id: string) => void markAnnotationApplied: (id: string) => void
linkAnnotationsToMessage: (messageId: string, annotationIds: string[]) => void linkAnnotationsToMessage: (messageId: string, annotationIds: string[]) => void
clearArchivedAnnotations: () => void
removeArchivedAnnotation: (id: string) => void
// Analysis mode // Analysis mode
analysisMode: AnalysisMode analysisMode: AnalysisMode
@@ -140,7 +142,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
activeFileContent: content, activeFileContent: content,
isDirty: false, isDirty: false,
chatHistory: existing, chatHistory: existing,
annotations: savedAnnotationState?.annotations.filter(a => !a.applied) ?? [], annotations: savedAnnotationState?.annotations.filter(a => !a.applied && !a.dismissed) ?? [],
analysisMode: savedAnnotationState?.mode ?? 'none' analysisMode: savedAnnotationState?.mode ?? 'none'
}) })
scheduleSave(() => { scheduleSave(() => {
@@ -282,11 +284,11 @@ 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 // Preserve applied and dismissed annotations so chat history links and archive remain intact
const existingApplied = s.activeFilePath const existingArchived = s.activeFilePath
? (s.annotationsByFile[s.activeFilePath]?.annotations ?? []).filter(a => a.applied) ? (s.annotationsByFile[s.activeFilePath]?.annotations ?? []).filter(a => a.applied || a.dismissed)
: [] : []
const merged = [...existingApplied, ...annotations] const merged = [...existingArchived, ...annotations]
const annotationsByFile = s.activeFilePath const annotationsByFile = s.activeFilePath
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations: merged } } ? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations: merged } }
: s.annotationsByFile : s.annotationsByFile
@@ -306,8 +308,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
removeAnnotation: (id) => { removeAnnotation: (id) => {
set((s) => { set((s) => {
const annotations = s.annotations.filter((a) => a.id !== id) const annotations = s.annotations.filter((a) => a.id !== id)
const fileAnnotations = s.activeFilePath
? (s.annotationsByFile[s.activeFilePath]?.annotations ?? [])
: []
const updatedAll = fileAnnotations.map(a => a.id === id ? { ...a, dismissed: true } : a)
const annotationsByFile = s.activeFilePath const annotationsByFile = s.activeFilePath
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations } } ? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations: updatedAll } }
: s.annotationsByFile : s.annotationsByFile
return { annotations, annotationsByFile } return { annotations, annotationsByFile }
}) })
@@ -324,9 +330,16 @@ export const useEditorStore = create<EditorState>((set, get) => ({
}, },
clearAnnotations: () => { clearAnnotations: () => {
set((s) => { set((s) => {
const annotationsByFile = s.activeFilePath if (!s.activeFilePath) return { annotations: [], analysisMode: 'none' as AnalysisMode }
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: 'none' as AnalysisMode, annotations: [] } } const fileState = s.annotationsByFile[s.activeFilePath]
: s.annotationsByFile // Archive active annotations as dismissed; leave already-archived items untouched
const updatedAll = (fileState?.annotations ?? []).map(a =>
!a.applied && !a.dismissed ? { ...a, dismissed: true } : a
)
const annotationsByFile = {
...s.annotationsByFile,
[s.activeFilePath]: { mode: 'none' as AnalysisMode, annotations: updatedAll }
}
return { annotations: [], analysisMode: 'none', annotationsByFile } return { annotations: [], analysisMode: 'none', annotationsByFile }
}) })
scheduleSave(() => { scheduleSave(() => {
@@ -392,6 +405,54 @@ export const useEditorStore = create<EditorState>((set, get) => ({
}) })
}, },
clearArchivedAnnotations: () => {
set((s) => {
if (!s.activeFilePath) return {}
const fileState = s.annotationsByFile[s.activeFilePath]
if (!fileState) return {}
return {
annotationsByFile: {
...s.annotationsByFile,
[s.activeFilePath]: { ...fileState, annotations: fileState.annotations.filter(a => !a.dismissed) }
}
}
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatSessionsByFile: st.chatSessionsByFile,
activeSessionIdByFile: st.activeSessionIdByFile,
annotationsByFile: st.annotationsByFile
}
})
},
removeArchivedAnnotation: (id) => {
set((s) => {
if (!s.activeFilePath) return {}
const fileState = s.annotationsByFile[s.activeFilePath]
if (!fileState) return {}
return {
annotationsByFile: {
...s.annotationsByFile,
[s.activeFilePath]: { ...fileState, annotations: fileState.annotations.filter(a => a.id !== id) }
}
}
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatSessionsByFile: st.chatSessionsByFile,
activeSessionIdByFile: st.activeSessionIdByFile,
annotationsByFile: st.annotationsByFile
}
})
},
analysisMode: 'none', analysisMode: 'none',
setAnalysisMode: (analysisMode) => { setAnalysisMode: (analysisMode) => {
set((s) => { set((s) => {

View File

@@ -39,6 +39,7 @@ export interface TextAnnotation {
message: string message: string
suggestion?: string suggestion?: string
applied?: boolean // true when the suggestion has been applied to the document applied?: boolean // true when the suggestion has been applied to the document
dismissed?: boolean // true when the user dismissed this annotation (archived)
} }
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'critique' export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'critique'