From f72ea7189cf5e5da6c21a2f726b927ffecd63abf Mon Sep 17 00:00:00 2001 From: CC Worker Date: Thu, 2 Jul 2026 21:08:38 +0000 Subject: [PATCH] FX-4: confirm/dismiss UI for AI ghost suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../exam/setup/ExamTemplateSetupPage.tsx | 83 ++++++++++++++++++- src/pages/exam/setup/examCanvasShapes.tsx | 17 ++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index c57acfc..be9b7a4 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -7,6 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline' import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh' import SaveIcon from '@mui/icons-material/Save' 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 { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw' import axios from 'axios' @@ -82,6 +85,21 @@ function reviewSummary(template: ExamTemplateDetail | null) { }, { 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) { return id.startsWith('shape:') ? id.slice('shape:'.length) : id } @@ -205,8 +223,11 @@ const ExamTemplateSetupInner: React.FC = () => { const [guideOpen, setGuideOpen] = useState(false) const [autoMapStatus, setAutoMapStatus] = useState(null) const [autoMapBusy, setAutoMapBusy] = useState(false) + const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 }) const autoMapPollRef = useRef(null) + const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), []) + const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => { setTemplate(detail) const editor = editorRef.current @@ -219,7 +240,33 @@ const ExamTemplateSetupInner: React.FC = () => { 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 () => { if (!templateId) return @@ -416,7 +463,23 @@ const ExamTemplateSetupInner: React.FC = () => { onMount={(editor) => { editorRef.current = editor 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, []) editor.resetZoom() // Only seed the example guide for a genuinely-empty template AFTER it has loaded. @@ -432,6 +495,22 @@ const ExamTemplateSetupInner: React.FC = () => { /> + {/* AI review actions — appears while there are unconfirmed suggestions to resolve */} + {review.unconfirmed > 0 && ( + + + + + + + + + + + + + )} + {/* Guide toggle */} setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}> diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index b388475..65935c5 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -81,6 +81,12 @@ function confidenceLabel(confidence: number | null | undefined) { 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[] { return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean) } @@ -100,16 +106,17 @@ function renderBoundaryLine(shape: ExamCanvasTLShape) { const p = canvasShapePalette.boundary const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2)) const isAi = shape.props.source === 'ai' + const isGhost = isAi && shape.props.confirmed === false const confidence = confidenceLabel(shape.props.confidence) const flags = reviewFlags(shape) return ( - + - + {shape.props.label || p.label} {confidence && {confidence}} @@ -139,8 +146,8 @@ function renderShape(shape: ExamCanvasTLShape) { '--exam-dark-stroke': p.darkStroke, '--exam-dark-fill': p.darkFill, 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, - background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif', + borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, + 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', 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} @@ -148,7 +155,7 @@ function renderShape(shape: ExamCanvasTLShape) { title={title} > - + {shape.props.label || p.label} {confidence && {confidence}}