✨ dismiss suggestions
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev:demo"],
|
||||||
|
"port": 5174
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user