FX-4: confirm/dismiss UI for AI ghost suggestions
The S5-7 review layer could surface unconfirmed AI shapes but had no way to resolve them (confirmed was never set true), so the only "dismiss" was deletion and the unconfirmed count never dropped. Add the resolution loop: - A floating review-actions panel (shown while unconfirmed > 0): Accept selected, Reject (delete) selected, and Accept all AI on the page. Accept flips props.confirmed = true. - Human edit = implicit confirm: nudging/resizing an AI ghost marks it confirmed (R3.3 — a teacher who adjusts a suggestion has reviewed it). Geometry-only trigger, so the confirmed-only update can't recurse. - Confidence-driven opacity: unconfirmed ghosts fade by confidence instead of a constant 0.72; confirmed AI renders solid with its kind icon (like manual). - Live review counts read straight off the canvas so the unconfirmed chip/panel update immediately on accept/reject (the old count reflected only saved state). Persists through the existing PUT (confirmed already round-trips). No schema or API change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7bd66fbaf0
commit
f72ea7189c
@ -7,6 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
|||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||||
import SaveIcon from '@mui/icons-material/Save'
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
import MouseIcon from '@mui/icons-material/Mouse'
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
|
import CheckIcon from '@mui/icons-material/Check'
|
||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import DoneAllIcon from '@mui/icons-material/DoneAll'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@ -82,6 +85,21 @@ function reviewSummary(template: ExamTemplateDetail | null) {
|
|||||||
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
|
||||||
|
// (the template-derived reviewSummary only reflects the last *saved* state).
|
||||||
|
function reviewFromShapes(editor: Editor | null) {
|
||||||
|
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
||||||
|
if (!editor) return acc
|
||||||
|
for (const shape of editor.getCurrentPageShapes()) {
|
||||||
|
if (!shapeTypeToKind(shape.type)) continue
|
||||||
|
const p = (shape as ExamCanvasTLShape).props
|
||||||
|
if (p.source === 'ai') acc.ai += 1
|
||||||
|
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
|
||||||
|
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
function stripShapePrefix(id: string) {
|
function stripShapePrefix(id: string) {
|
||||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
}
|
}
|
||||||
@ -205,8 +223,11 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const [guideOpen, setGuideOpen] = useState(false)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
||||||
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||||
|
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
const autoMapPollRef = useRef<number | null>(null)
|
const autoMapPollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
|
||||||
|
|
||||||
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
setTemplate(detail)
|
setTemplate(detail)
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
@ -219,7 +240,33 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
setDirty(false)
|
setDirty(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const review = useMemo(() => reviewSummary(template), [template])
|
const acceptShapes = useCallback((shapes: TLShape[]) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const updates = shapes
|
||||||
|
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
|
||||||
|
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
|
||||||
|
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview])
|
||||||
|
|
||||||
|
const selectedDomainShapes = useCallback((): TLShape[] => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return []
|
||||||
|
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
|
||||||
|
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
|
||||||
|
const rejectSelected = useCallback(() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const ids = selectedDomainShapes().map((s) => s.id)
|
||||||
|
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
|
||||||
|
}, [refreshReview, selectedDomainShapes])
|
||||||
|
|
||||||
|
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
|
||||||
|
// saved-template summary only for the brief moment before shapes have mounted).
|
||||||
|
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@ -416,7 +463,23 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
|
||||||
|
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
|
||||||
|
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
|
||||||
|
// update below can't recurse.
|
||||||
|
editor.store.listen((entry) => {
|
||||||
|
try {
|
||||||
|
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
|
||||||
|
for (const rec of Object.values(entry.changes.updated)) {
|
||||||
|
const [from, to] = rec as [any, any]
|
||||||
|
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
|
||||||
|
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
|
||||||
|
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
|
||||||
|
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
|
||||||
|
}
|
||||||
|
if (updates.length) editor.updateShapes(updates as any)
|
||||||
|
} catch { /* non-fatal: implicit-confirm is best-effort */ }
|
||||||
|
}, { scope: 'document', source: 'user' })
|
||||||
applyDocViewConstraints(editor, [])
|
applyDocViewConstraints(editor, [])
|
||||||
editor.resetZoom()
|
editor.resetZoom()
|
||||||
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||||
@ -432,6 +495,22 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
|
||||||
|
{review.unconfirmed > 0 && (
|
||||||
|
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
|
||||||
|
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
|
||||||
|
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reject (delete) the selected shapes">
|
||||||
|
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Accept every AI suggestion on the page">
|
||||||
|
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
|
||||||
|
</Tooltip>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Guide toggle */}
|
{/* Guide toggle */}
|
||||||
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||||
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||||
|
|||||||
@ -81,6 +81,12 @@ function confidenceLabel(confidence: number | null | undefined) {
|
|||||||
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
|
||||||
|
function ghostOpacity(confidence: number | null | undefined) {
|
||||||
|
if (typeof confidence !== 'number') return 0.72
|
||||||
|
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
|
||||||
|
}
|
||||||
|
|
||||||
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
||||||
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
@ -100,16 +106,17 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
|||||||
const p = canvasShapePalette.boundary
|
const p = canvasShapePalette.boundary
|
||||||
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
const isAi = shape.props.source === 'ai'
|
const isAi = shape.props.source === 'ai'
|
||||||
|
const isGhost = isAi && shape.props.confirmed === false
|
||||||
const confidence = confidenceLabel(shape.props.confidence)
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
const flags = reviewFlags(shape)
|
const flags = reviewFlags(shape)
|
||||||
return (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||||
<style>{shapeCss}</style>
|
<style>{shapeCss}</style>
|
||||||
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
@ -139,8 +146,8 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
'--exam-dark-stroke': p.darkStroke,
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
'--exam-dark-fill': p.darkFill,
|
'--exam-dark-fill': p.darkFill,
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
@ -148,7 +155,7 @@ function renderShape(shape: ExamCanvasTLShape) {
|
|||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
<span aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user