✨ feedback archives
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +226,8 @@ export function FeedbackPanel(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fb-panel">
|
<div className="fb-panel">
|
||||||
|
{hasActive && (
|
||||||
|
<>
|
||||||
<div className="fb-toolbar">
|
<div className="fb-toolbar">
|
||||||
<button
|
<button
|
||||||
className="fb-toolbar-btn"
|
className="fb-toolbar-btn"
|
||||||
@@ -189,7 +240,7 @@ export function FeedbackPanel(): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
className="fb-toolbar-btn fb-toolbar-btn--clear"
|
className="fb-toolbar-btn fb-toolbar-btn--clear"
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
title="Remove all highlights"
|
title="Archive all highlights"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</button>
|
</button>
|
||||||
@@ -205,6 +256,37 @@ export function FeedbackPanel(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user