Merge fx-4-confirm-dismiss
This commit is contained in:
commit
2b33aa80eb
@ -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<AutoMapJobStatus | null>(null)
|
||||
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||
const autoMapPollRef = useRef<number | null>(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 = () => {
|
||||
/>
|
||||
</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 */}
|
||||
<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' } }}>
|
||||
|
||||
@ -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 (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||
<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' }}>
|
||||
<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>
|
||||
<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}
|
||||
</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-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}
|
||||
>
|
||||
<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}
|
||||
</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