From 3432fca481aac892b05b468af5bb87619269dbc1 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 17 Jul 2026 13:31:35 +0200 Subject: [PATCH] feat: add hierarchical lesson session controls --- react/src/main.tsx | 570 ++++++++++++++++++++++++++++++++------------ react/src/react.css | 6 +- 2 files changed, 418 insertions(+), 158 deletions(-) diff --git a/react/src/main.tsx b/react/src/main.tsx index 7500af4..4e8106d 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -1,8 +1,47 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import "./react.css"; -type Status = "not_started" | "discussed" | "completed"; +type Status = "pending" | "approved"; +type NavigationLevel = "task" | "block" | "phase" | "step" | "snapshot"; +type NavigationPosition = { + task_index: number; + block_index: number; + phase_index: number; + step_index: number; + global_step_index: number; + snapshot_index: number | null; + global_snapshot_index: number | null; +}; +type ApiNavigation = { + revision: number; + actor?: string; + focus_level: NavigationLevel; + task: { id: string; label: string; index: number }; + block: { id: string; label: string; index: number }; + phase: { id: string; label: string; index: number }; + step: { id: string; label: string; number: number; index: number; global_index: number }; + snapshot_ref: string | null; + position: NavigationPosition; + sync_requested: boolean; +}; +type ApiViewerState = { + revision: number; + actor?: string | null; + scale: number; + scroll: { x: number; y: number; page_index: number }; + viewport: { width: number; height: number }; + nvim: { + visible: boolean; + sync: boolean; + control: boolean; + expanded: boolean; + fit: boolean; + connection: string; + screen_cursor: { row: number; column: number } | null; + grid: { columns: number; rows: number } | null; + }; +}; type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text"; type InteractiveSymbol = { id: string; @@ -204,6 +243,8 @@ type NvimPanelSummary = { syncReady: boolean; controlMode: boolean; controlEnabled: boolean; + screenCursor?: { row: number; column: number }; + grid?: { columns: number; rows: number }; }; const nvimStatusLabel: Record = { @@ -408,6 +449,7 @@ type ActiveNvimStep = { block: { id?: string; label?: string } | null; phase: { id: string; label: string }; step: UmlSequenceStep; + focusLevel?: NavigationLevel; activate?: boolean; }; type NvimConnectionStatus = { @@ -901,6 +943,7 @@ function NeovimPanel({ const websocketRef = useRef(null); const redrawFrameRef = useRef(0); const gridFlushRef = useRef(0); + const gridReportTimerRef = useRef(0); const gridRef = useRef(createNvimGrid()); const targetEpochRef = useRef(null); const activeStepRef = useRef(null); @@ -910,6 +953,7 @@ function NeovimPanel({ const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null); const [activeStep, setActiveStep] = useState(null); const [hasGrid, setHasGrid] = useState(false); + const [gridRevision, setGridRevision] = useState(0); const [pointerInside, setPointerInside] = useState(false); const [panelHeightVh, setPanelHeightVh] = useState(() => { const saved = Number(localStorage.getItem("stem-card-nvim-height-vh")); @@ -947,22 +991,6 @@ function NeovimPanel({ setActiveStep(detail); presentedSnapshotRef.current = null; const snapshotRef = detail.step.snapshot_ref; - void fetch("/api/navigation/current", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - task: detail.task, - block: detail.block, - phase: detail.phase, - step: { - id: detail.step.id, - number: detail.step.number, - label: detail.step.label, - }, - snapshot_ref: snapshotRef ?? null, - sync_requested: Boolean(detail.activate), - }), - }).catch(() => undefined); if (status.state !== "connected" && snapshotRef) { void loadRecordedNvimGrid(detail.step).then(saved => { if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return; @@ -1139,8 +1167,15 @@ function NeovimPanel({ syncReady, controlMode, controlEnabled, + screenCursor: { ...gridRef.current.cursor }, + grid: { + columns: gridRef.current.width, + rows: gridRef.current.height, + }, }); - }, [activeStep, checkpoint, controlEnabled, controlMode, onSummaryChange, status, syncMode, syncReady]); + }, [activeStep, checkpoint, controlEnabled, controlMode, gridRevision, onSummaryChange, status, syncMode, syncReady]); + + useEffect(() => () => window.clearTimeout(gridReportTimerRef.current), []); useEffect(() => { if (!controlEnabled) return; @@ -1214,6 +1249,12 @@ function NeovimPanel({ )) { gridFlushRef.current += 1; scheduleRedraw(); + if (!gridReportTimerRef.current) { + gridReportTimerRef.current = window.setTimeout(() => { + gridReportTimerRef.current = 0; + setGridRevision(current => current + 1); + }, 120); + } } } } catch { @@ -1786,7 +1827,7 @@ function LegacyInteractiveUmlStages({ ); const isCompleted = Boolean( item.progress_id && - progress?.items?.[item.progress_id]?.status === "completed", + progress?.items?.[item.progress_id]?.status === "approved", ); sameRegion.forEach(({ rect }, duplicateIndex) => { const isBackground = rect.getAttribute("fill") !== "none"; @@ -1854,14 +1895,14 @@ function LegacyInteractiveUmlStages({ aria-pressed={stageIndex === index} data-completed={ item.progress_id && - progress?.items?.[item.progress_id]?.status === "completed" + progress?.items?.[item.progress_id]?.status === "approved" ? "true" : "false" } onClick={() => setStageIndex(index)} > {item.progress_id && - progress?.items?.[item.progress_id]?.status === "completed" && ( + progress?.items?.[item.progress_id]?.status === "approved" && ( )} {item.label} @@ -1901,7 +1942,7 @@ function LegacyInteractiveUmlStages({ {stage?.progress_id && (