dismiss suggestions

This commit is contained in:
2026-02-26 12:12:07 +10:00
parent 525f795447
commit 1bc0a82e73
6 changed files with 120 additions and 31 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "demo",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:demo"],
"port": 5174
}
]
}

View File

@@ -15,13 +15,25 @@ import './Editor.css'
// StateEffect to push new annotations into the editor // StateEffect to push new annotations into the editor
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>() export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
// StateField stores the raw annotation array for hover lookup // StateField stores the raw annotation array for hover lookup.
// Positions are mapped through every document change so the field always
// reflects where annotations actually are in the current document.
// Zero-width entries (from === to after a deletion) are intentionally kept:
// if the user cuts text and pastes it back at the same spot, the position
// expands and the decoration reappears automatically.
const rawAnnotationsField = StateField.define<TextAnnotation[]>({ const rawAnnotationsField = StateField.define<TextAnnotation[]>({
create: () => [], create: () => [],
update(annotations, tr) { update(annotations, tr) {
for (const effect of tr.effects) { for (const effect of tr.effects) {
if (effect.is(setAnnotationsEffect)) return effect.value if (effect.is(setAnnotationsEffect)) return effect.value
} }
if (tr.docChanged && annotations.length > 0) {
return annotations.map(a => ({
...a,
from: tr.changes.mapPos(a.from, -1),
to: tr.changes.mapPos(a.to, 1)
}))
}
return annotations return annotations
} }
}) })
@@ -194,32 +206,32 @@ const annotationHoverTooltip = hoverTooltip(
{ hoverTime: 500 } { hoverTime: 500 }
) )
// StateField tracks the decoration set derived from annotations // Build a DecorationSet from an annotation list, clamped to docLen.
function buildDecoSet(annotations: TextAnnotation[], docLen: number): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
const sorted = [...annotations].sort((a, b) => a.from - b.from)
for (const ann of sorted) {
const from = Math.max(0, Math.min(ann.from, docLen))
const to = Math.max(from, Math.min(ann.to, docLen))
if (from < to) {
builder.add(from, to, Decoration.mark({
class: `annotation annotation-${ann.type}`,
attributes: { 'data-id': ann.id }
}))
}
}
return builder.finish()
}
// StateField tracks the decoration set derived from annotations.
// Rebuilds from rawAnnotationsField on every doc change (not deco.map) so that
// position-mapped highlights update immediately, and a decoration whose text
// was cut and pasted back at the same spot reappears without any extra action.
const annotationField = StateField.define<DecorationSet>({ const annotationField = StateField.define<DecorationSet>({
create: () => Decoration.none, create: () => Decoration.none,
update(deco, tr) { update(deco, tr) {
deco = deco.map(tr.changes) if (tr.docChanged || tr.effects.some(e => e.is(setAnnotationsEffect))) {
for (const effect of tr.effects) { return buildDecoSet(tr.state.field(rawAnnotationsField), tr.newDoc.length)
if (effect.is(setAnnotationsEffect)) {
const builder = new RangeSetBuilder<Decoration>()
const sorted = [...effect.value].sort((a, b) => a.from - b.from)
for (const ann of sorted) {
const docLen = tr.newDoc.length
const from = Math.max(0, Math.min(ann.from, docLen))
const to = Math.max(from, Math.min(ann.to, docLen))
if (from < to) {
builder.add(
from,
to,
Decoration.mark({
class: `annotation annotation-${ann.type}`,
attributes: { 'data-id': ann.id }
})
)
}
}
return builder.finish()
}
} }
return deco return deco
}, },
@@ -412,11 +424,16 @@ export function MarkdownEditor(): JSX.Element {
} }
}, [activeFilePath]) // Only sync on file switch }, [activeFilePath]) // Only sync on file switch
// Push annotation decorations into CodeMirror // Push annotation decorations into CodeMirror.
// Merge the store's list (which annotations exist) with CM-tracked positions
// (where they actually are after any edits), so that dismissing or adding an
// annotation doesn't reset surviving highlights to stale store positions.
useEffect(() => { useEffect(() => {
const view = viewRef.current const view = viewRef.current
if (!view) return if (!view) return
view.dispatch({ effects: setAnnotationsEffect.of(annotations) }) const trackedById = new Map(view.state.field(rawAnnotationsField).map(a => [a.id, a]))
const toDispatch = annotations.map(a => trackedById.get(a.id) ?? a)
view.dispatch({ effects: setAnnotationsEffect.of(toDispatch) })
}, [annotations]) }, [annotations])
// Reconfigure theme when font size or colour theme changes // Reconfigure theme when font size or colour theme changes

View File

@@ -96,6 +96,33 @@
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
.fb-card-header-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.fb-card-dismiss {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
line-height: 1;
padding: 0 2px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
}
.fb-card:hover .fb-card-dismiss {
opacity: 1;
}
.fb-card-dismiss:hover {
color: var(--text-primary);
}
/* Coloured type badge */ /* Coloured type badge */
.fb-card-badge { .fb-card-badge {
font-size: 10px; font-size: 10px;

View File

@@ -33,9 +33,10 @@ function renderMarkdown(text: string, streaming: boolean): string {
interface FeedbackCardProps { interface FeedbackCardProps {
ann: TextAnnotation ann: TextAnnotation
autoAnalyse: boolean autoAnalyse: boolean
onDismiss: () => void
} }
function FeedbackCard({ ann, autoAnalyse }: FeedbackCardProps): JSX.Element { function FeedbackCard({ ann, autoAnalyse, onDismiss }: FeedbackCardProps): JSX.Element {
const [state, setState] = useState<AnalysisState>(() => { const [state, setState] = useState<AnalysisState>(() => {
const cached = tooltipAnalysisCache.get(ann.id) const cached = tooltipAnalysisCache.get(ann.id)
if (cached) return { status: 'done', text: cached.text, suggestion: cached.suggestion } if (cached) return { status: 'done', text: cached.text, suggestion: cached.suggestion }
@@ -82,7 +83,16 @@ function FeedbackCard({ ann, autoAnalyse }: FeedbackCardProps): JSX.Element {
> >
{/* Header — click to jump to passage in editor */} {/* Header — click to jump to passage in editor */}
<div className="fb-card-header" onClick={() => scrollToAnnotation(ann)} title="Jump to passage"> <div className="fb-card-header" onClick={() => scrollToAnnotation(ann)} title="Jump to passage">
<span className="fb-card-badge">{typeName}</span> <div className="fb-card-header-top">
<span className="fb-card-badge">{typeName}</span>
<button
className="fb-card-dismiss"
onClick={(e) => { e.stopPropagation(); onDismiss() }}
title="Dismiss"
>
×
</button>
</div>
<span className="fb-card-excerpt">"{ann.matchedText}"</span> <span className="fb-card-excerpt">"{ann.matchedText}"</span>
</div> </div>
@@ -124,7 +134,7 @@ function FeedbackCard({ ann, autoAnalyse }: FeedbackCardProps): JSX.Element {
} }
export function FeedbackPanel(): JSX.Element { export function FeedbackPanel(): JSX.Element {
const { annotations, setAnnotations } = useEditorStore() const { annotations, setAnnotations, removeAnnotation } = useEditorStore()
const [analyseAll, setAnalyseAll] = useState(false) const [analyseAll, setAnalyseAll] = useState(false)
// Reset "Analyse all" whenever the annotation set changes (new critique run), // Reset "Analyse all" whenever the annotation set changes (new critique run),
@@ -175,7 +185,12 @@ export function FeedbackPanel(): JSX.Element {
<div className="fb-list"> <div className="fb-list">
{annotations.map(ann => ( {annotations.map(ann => (
<FeedbackCard key={ann.id} ann={ann} autoAnalyse={analyseAll} /> <FeedbackCard
key={ann.id}
ann={ann}
autoAnalyse={analyseAll}
onDismiss={() => { removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
/>
))} ))}
</div> </div>
</div> </div>

View File

@@ -35,6 +35,7 @@ interface EditorState {
annotations: TextAnnotation[] annotations: TextAnnotation[]
annotationsByFile: Record<string, AnnotationFileState> annotationsByFile: Record<string, AnnotationFileState>
setAnnotations: (annotations: TextAnnotation[]) => void setAnnotations: (annotations: TextAnnotation[]) => void
removeAnnotation: (id: string) => void
clearAnnotations: () => void clearAnnotations: () => void
// Analysis mode // Analysis mode
@@ -252,6 +253,24 @@ export const useEditorStore = create<EditorState>((set, get) => ({
} }
}) })
}, },
removeAnnotation: (id) => {
set((s) => {
const annotations = s.annotations.filter((a) => a.id !== id)
const annotationsByFile = s.activeFilePath
? { ...s.annotationsByFile, [s.activeFilePath]: { mode: s.analysisMode, annotations } }
: s.annotationsByFile
return { annotations, annotationsByFile }
})
scheduleSave(() => {
const st = get()
return {
activeFilePath: st.activeFilePath,
scrollPositions: st.scrollPositions,
chatHistoryByFile: st.chatHistoryByFile,
annotationsByFile: st.annotationsByFile
}
})
},
clearAnnotations: () => { clearAnnotations: () => {
set((s) => { set((s) => {
const annotationsByFile = s.activeFilePath const annotationsByFile = s.activeFilePath

File diff suppressed because one or more lines are too long