From f4f7a26eb8f061929435da6a31832f095825f18e Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 02:35:06 -0400 Subject: [PATCH 01/14] feat(maic-editor): slide nav rail + scene management (3/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR3a Phase 1 ships the Pro mode left rail + slide-level management, the last user-visible block of #562. Closes the gap where Pro mode locked the user on the current scene with no way to navigate or manage the deck. SlideNavRail (Studio Editor aesthetic, mirrors playback `SceneSidebar` visually — index badge + title above an aspect-video thumbnail card — so the two sidebars read as the same component family across mode toggle): - Vertical thumbnail strip via `motion.dev` `Reorder.Group` with drag-to-reorder. `Reorder.Item layout="position"` keeps the layout animation on y-axis only; width changes from rail resize don't fight. - Drag-to-resize handle on the right edge writes `style.width` directly on the DOM during the gesture and commits to settings store only on mouse-up; matches playback drag feel exactly and skips the per-frame `persist` serialization that would otherwise burn the frame budget at 60 Hz. - Collapsed and expanded modes; width and collapsed flag persist in `useSettingsStore` (`editRailWidth`, `editRailCollapsed`). - All scene types are first-class — slides render a live `ThumbnailSlide` (now with optional `size` prop → self-measures via `ResizeObserver` when omitted, so the rail width is the single source of truth), non-slide scenes render the same stylised mockups playback `SceneSidebar` uses (extracted to `SceneThumbnailContent`). Slide management: - `+ Add` in the rail header inserts a blank slide after the current scene; new store action `useStageStore.insertSceneAfter` validates stage id, migrates the scene, splices, rebalances `order`, and triggers `debouncedSave`. - Three-dot menu per tile: Rename / Duplicate / Delete. Rename also reachable via double-click on the title; Enter commits, Escape cancels, blur commits, empty input reverts. - Duplicate deep-clones slide content with fresh element IDs (avoid React key collisions) and a `(copy)` title suffix. - Delete uses a toast with Undo action; deleted scene is held in a small `useDeletedSceneRecycle` zustand store and re-inserted at its original index on Undo. Deck-empty guard at the rail layer. - Inter-thumb `InsertionZone` reveals a violet `+` badge on hover, right-anchored, with a popup motion (`cubic-bezier(0.34,1.56,0.64,1)`) + drop shadow + `z-20` so it lifts above the active tile's violet ring. Zero layout shift. Chrome bar: - `HeaderControls` (settings pill + Pro Switch) extracted from `Header` so Pro mode can mount it in the CommandBar's trailing slot — single top chrome bar in Pro mode instead of stacking Header + CommandBar. - Back-to-home button in CommandBar mirrors the playback Header's leftmost button. i18n: new `edit.nav.*` namespace across en-US / zh-CN / zh-TW / ja-JP / ar-SA / ru-RU. Tests: vitest for `insertSceneAfter`, `useDeletedSceneRecycle`, `createBlankSlideScene` / `duplicateSlideScene`. Co-Authored-By: Claude Opus 4.7 --- components/edit/EditShell/CommandBar.tsx | 65 ++- .../edit/SlideNavRail/InsertionZone.tsx | 63 +++ components/edit/SlideNavRail/SlideNavRail.tsx | 413 ++++++++++++++++++ components/edit/SlideNavRail/ThumbItem.tsx | 290 ++++++++++++ components/edit/SlideNavRail/index.ts | 1 + components/header.tsx | 182 ++------ components/language-switcher.tsx | 87 ++-- .../components/ThumbnailSlide/index.tsx | 75 +++- components/stage/header-controls.tsx | 171 ++++++++ components/stage/scene-thumbnail-content.tsx | 177 ++++++++ lib/edit/deleted-scene-recycle.ts | 41 ++ lib/edit/slide-defaults.ts | 89 ++++ lib/edit/transitions.ts | 29 ++ lib/i18n/locales/ar-SA.json | 16 + lib/i18n/locales/en-US.json | 16 + lib/i18n/locales/ja-JP.json | 16 + lib/i18n/locales/ru-RU.json | 16 + lib/i18n/locales/zh-CN.json | 16 + lib/i18n/locales/zh-TW.json | 16 + lib/store/settings.ts | 8 + lib/store/stage.ts | 23 + tests/edit/deleted-scene-recycle.test.ts | 71 +++ tests/edit/slide-defaults.test.ts | 127 ++++++ tests/store/stage-insert-scene-after.test.ts | 97 ++++ 24 files changed, 1859 insertions(+), 246 deletions(-) create mode 100644 components/edit/SlideNavRail/InsertionZone.tsx create mode 100644 components/edit/SlideNavRail/SlideNavRail.tsx create mode 100644 components/edit/SlideNavRail/ThumbItem.tsx create mode 100644 components/edit/SlideNavRail/index.ts create mode 100644 components/stage/header-controls.tsx create mode 100644 components/stage/scene-thumbnail-content.tsx create mode 100644 lib/edit/deleted-scene-recycle.ts create mode 100644 lib/edit/slide-defaults.ts create mode 100644 lib/edit/transitions.ts create mode 100644 tests/edit/deleted-scene-recycle.test.ts create mode 100644 tests/edit/slide-defaults.test.ts create mode 100644 tests/store/stage-insert-scene-after.test.ts diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx index 09c3751e73..41e805cbec 100644 --- a/components/edit/EditShell/CommandBar.tsx +++ b/components/edit/EditShell/CommandBar.tsx @@ -1,6 +1,8 @@ 'use client'; -import { Redo2, Undo2 } from 'lucide-react'; +import { ArrowLeft, Redo2, Undo2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -17,22 +19,37 @@ interface CommandBarProps { readonly history?: SurfaceHistory; readonly insertItems?: readonly InsertPaletteItem[]; readonly commands?: readonly EditorCommand[]; + /** + * Right-edge slot owned by Stage. In Pro mode it carries the + * HeaderControls (settings pill + Pro Switch) since Stage Header is + * unmounted to keep top chrome to a single bar. + */ + readonly trailing?: ReactNode; } /** * Top bar of the Pro mode chrome. Undo/redo + title on the left, insert * primitives in the center, surface commands on the right. History / * insertItems / commands are all optional so the bar renders cleanly when - * no surface is registered for the current scene type. Exiting Pro mode - * is handled by the global Pro toggle in the playback Header (which stays - * mounted above this bar), not by a dedicated button here. + * no surface is registered for the current scene type. + * + * Exiting Pro mode is handled by the global Pro Switch in the playback + * Header (which stays mounted above this bar) — Pro mode is a toggle, + * not a one-way state, so we deliberately do *not* place a "Done" pill + * here that would compete with the Switch's affordance. */ -export function CommandBar({ title, history, insertItems, commands }: CommandBarProps) { +export function CommandBar({ title, history, insertItems, commands, trailing }: CommandBarProps) { const { t } = useI18n(); + const router = useRouter(); return (
-
+
+ {/* Back-to-home — mirrors playback Header's leftmost button so the + user has the same global-out affordance across modes. */} + router.push('/')}> + + {history && ( <> @@ -45,9 +62,10 @@ export function CommandBar({ title, history, insertItems, commands }: CommandBar )} {title} @@ -61,20 +79,23 @@ export function CommandBar({ title, history, insertItems, commands }: CommandBar
)} - {commands && commands.length > 0 && ( -
- {commands.map((command) => ( - - {command.icon ?? {command.label}} - - ))} -
- )} +
+ {commands && commands.length > 0 && ( +
+ {commands.map((command) => ( + + {command.icon ?? {command.label}} + + ))} +
+ )} + {trailing &&
{trailing}
} +
); } diff --git a/components/edit/SlideNavRail/InsertionZone.tsx b/components/edit/SlideNavRail/InsertionZone.tsx new file mode 100644 index 0000000000..c77288639f --- /dev/null +++ b/components/edit/SlideNavRail/InsertionZone.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface InsertionZoneProps { + readonly label: string; + readonly onInsert: () => void; +} + +/** + * Hover-revealed insertion affordance between two thumbs. + * + * The gap is a slim 8px hit zone that matches playback `SceneSidebar`'s + * `space-y-2` density (no layout shift, ever). On hover the `+` badge + * pops out to the right side of the gap with a small overshoot, sitting + * on its own z-layer with a solid background + soft drop shadow so it + * clearly floats above any adjacent violet ring. + */ +export function InsertionZone({ label, onInsert }: InsertionZoneProps) { + // `z-20` lifts the whole zone above adjacent `Reorder.Item` siblings. + // Without this, the next-in-DOM-order ThumbItem (which has a `transform` + // via motion's Reorder, creating its own stacking context) paints on + // top, and its violet ring clips through the `+` badge regardless of + // any z-index applied inside the InsertionZone itself. + return ( +
+ + + + +
+ ); +} diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx new file mode 100644 index 0000000000..21e6875949 --- /dev/null +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AnimatePresence, Reorder, motion, useReducedMotion } from 'motion/react'; +import { PanelLeftClose, PanelLeftOpen, PlusCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { useStageStore } from '@/lib/store'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { useDeletedSceneRecycle } from '@/lib/edit/deleted-scene-recycle'; +import { createBlankSlideScene, duplicateSlideScene } from '@/lib/edit/slide-defaults'; +import { CHROME_DURATION_MS, CHROME_EASE, CHROME_EASE_CSS } from '@/lib/edit/transitions'; +import type { Scene } from '@/lib/types/stage'; +import { ThumbItem } from './ThumbItem'; +import { InsertionZone } from './InsertionZone'; + +const RAIL_COLLAPSED_PX = 56; +const RAIL_MIN_PX = 180; +const RAIL_MAX_PX = 360; + +/** + * Pro mode slide-navigation left rail (Studio Editor aesthetic). + * + * Layout: a vertical thumbnail strip with monospaced index captions + * below each tile, inter-thumb "+" insertion zones revealed on hover, + * and a collapse toggle at the rail head. All scene types are + * first-class — slides render a live `ThumbnailSlide`, non-slide scenes + * get a type-icon stub but stay clickable, draggable, and right-clickable + * so page-level management is uniform across the deck. + * + * Visuals: low-chroma zinc surface + single violet brand accent, no + * per-row chrome (rejected `EditModeSidebar` pattern). Drag uses an + * explicit grip handle on the thumb so the whole tile remains + * click-to-switch. + */ +export function SlideNavRail() { + const { t } = useI18n(); + const scenes = useStageStore.use.scenes(); + const currentSceneId = useStageStore.use.currentSceneId(); + const setCurrentSceneId = useStageStore.use.setCurrentSceneId(); + const setScenes = useStageStore.use.setScenes(); + const insertSceneAfter = useStageStore.use.insertSceneAfter(); + const deleteScene = useStageStore.use.deleteScene(); + const stage = useStageStore.use.stage(); + const collapsed = useSettingsStore((s) => s.editRailCollapsed); + const setCollapsed = useSettingsStore((s) => s.setEditRailCollapsed); + const persistedWidth = useSettingsStore((s) => s.editRailWidth); + const setPersistedWidth = useSettingsStore((s) => s.setEditRailWidth); + const prefersReducedMotion = useReducedMotion(); + + // Drag-to-resize. + // + // We mutate the rail's `style.width` directly on the DOM during pointer + // move (bypassing React entirely) and only commit the final width to the + // settings store on mouse-up. This is what makes the handle feel glued + // to the cursor: there's no React render → reconcile → DOM commit + // latency between mousemove and the visible width change. The thumbnails + // inside (which depend on the rail's CSS width via ResizeObserver in + // `ThumbnailSlide`) get notified by the browser's layout engine on the + // same frame, so they scale in lock-step. + // + // `isDragging` is still React state so we can turn off the CSS + // `transition: width` for the duration of the gesture — otherwise the + // 280ms tween from the collapse/expand animation would fight every + // direct width write. + const railRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + if (collapsed) return; + e.preventDefault(); + const startX = e.clientX; + const startWidth = persistedWidth; + let lastWidth = startWidth; + setIsDragging(true); + const onMove = (me: MouseEvent) => { + const delta = me.clientX - startX; + const next = Math.min(RAIL_MAX_PX, Math.max(RAIL_MIN_PX, startWidth + delta)); + lastWidth = next; + if (railRef.current) railRef.current.style.width = `${next}px`; + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + // Commit final width to persisted settings exactly once per gesture. + // React will re-render with `style.width = persistedWidth`, which + // matches the DOM value we already wrote — no visual jump. + setPersistedWidth(lastWidth); + setIsDragging(false); + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [collapsed, persistedWidth, setPersistedWidth], + ); + + useEffect( + () => () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }, + [], + ); + + const slideCount = useMemo(() => scenes.filter((s) => s.type === 'slide').length, [scenes]); + // For non-slide scenes (no recreate path), only allow delete if there's + // more than one scene overall — otherwise the deck would become empty. + const totalScenes = scenes.length; + + const currentScene = useMemo( + () => scenes.find((s) => s.id === currentSceneId) ?? null, + [scenes, currentSceneId], + ); + + const onReorderIds = useCallback( + (newOrder: string[]) => { + const byId = new Map(scenes.map((s) => [s.id, s] as const)); + const next: Scene[] = newOrder + .map((id) => byId.get(id)) + .filter((s): s is Scene => Boolean(s)); + if (next.length !== scenes.length) return; + const rebalanced = next.map((s, i) => (s.order === i + 1 ? s : { ...s, order: i + 1 })); + setScenes(rebalanced); + }, + [scenes, setScenes], + ); + + const handleActivate = useCallback( + (sceneId: string) => { + if (sceneId === currentSceneId) return; + // Switching to a non-slide scene is fine — useEditModeLock will + // auto-exit Pro mode the moment the new scene is uneditable. + setCurrentSceneId(sceneId); + }, + [currentSceneId, setCurrentSceneId], + ); + + const handleInsertAt = useCallback( + (afterSceneId: string | null) => { + if (!stage) return; + const anchor = afterSceneId + ? scenes.find((s) => s.id === afterSceneId) + : (currentScene ?? scenes[scenes.length - 1]); + if (!anchor) return; + const anchorIndex = scenes.findIndex((s) => s.id === anchor.id); + const newOrder = anchorIndex + 2; + const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), newOrder); + insertSceneAfter(anchor.id, blank); + setCurrentSceneId(blank.id); + }, + [currentScene, insertSceneAfter, scenes, setCurrentSceneId, stage, t], + ); + + const handleDuplicate = useCallback( + (sceneId: string) => { + const source = scenes.find((s) => s.id === sceneId); + if (!source) return; + const anchorIndex = scenes.findIndex((s) => s.id === sceneId); + const newOrder = anchorIndex + 2; + // Slide scenes get a deep clone with reseeded element IDs; non-slide + // scenes just get a shallow id + title bump. + const copy: Scene = + source.type === 'slide' + ? duplicateSlideScene(source, t('edit.nav.copySuffix'), newOrder) + : { + ...source, + id: crypto.randomUUID(), + title: `${source.title} ${t('edit.nav.copySuffix')}`, + order: newOrder, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + insertSceneAfter(sceneId, copy); + setCurrentSceneId(copy.id); + }, + [insertSceneAfter, scenes, setCurrentSceneId, t], + ); + + const handleDelete = useCallback( + (sceneId: string) => { + const source = scenes.find((s) => s.id === sceneId); + if (!source) return; + // Hold deck-empty guard at the rail layer; the store doesn't enforce. + if (source.type === 'slide' && slideCount <= 1) return; + if (totalScenes <= 1) return; + const index = scenes.findIndex((s) => s.id === sceneId); + useDeletedSceneRecycle.getState().capture(source, index); + deleteScene(sceneId); + toast(t('edit.nav.deleted'), { + description: source.title, + duration: 5000, + action: { + label: t('edit.nav.undo'), + onClick: () => { + const entry = useDeletedSceneRecycle.getState().consume(); + if (!entry) return; + const live = useStageStore.getState().scenes; + const anchorIndex = Math.min(Math.max(entry.index - 1, 0), live.length - 1); + const anchor = live[anchorIndex]; + if (!anchor) { + useStageStore.getState().setScenes([entry.scene]); + useStageStore.getState().setCurrentSceneId(entry.scene.id); + return; + } + useStageStore.getState().insertSceneAfter(anchor.id, entry.scene); + useStageStore.getState().setCurrentSceneId(entry.scene.id); + }, + }, + onDismiss: () => useDeletedSceneRecycle.getState().clear(), + onAutoClose: () => useDeletedSceneRecycle.getState().clear(), + }); + }, + [deleteScene, scenes, slideCount, totalScenes, t], + ); + + const canDeleteAny = totalScenes > 1; + const canDeleteSlide = slideCount > 1; + + // Plain CSS transition mirrors playback `SceneSidebar` exactly: zero + // motion.dev overhead, instant width updates while dragging. The earlier + // `motion.aside animate={false}` still ran motion's element-tracking + // pipeline per frame even with animation off, which produced the + // perceptible drag lag the user reported. + const widthTransitionCss = isDragging + ? 'none' + : prefersReducedMotion + ? 'none' + : `width ${CHROME_DURATION_MS}ms ${CHROME_EASE_CSS}`; + + return ( + + ); +} + +interface CollapsedListProps { + readonly scenes: readonly Scene[]; + readonly currentSceneId: string | null; + readonly onActivate: (sceneId: string) => void; +} + +function CollapsedList({ scenes, currentSceneId, onActivate }: CollapsedListProps) { + return ( +
    + {scenes.map((scene, index) => { + const active = scene.id === currentSceneId; + const isSlide = scene.type === 'slide'; + return ( +
  1. + +
  2. + ); + })} +
+ ); +} diff --git a/components/edit/SlideNavRail/ThumbItem.tsx b/components/edit/SlideNavRail/ThumbItem.tsx new file mode 100644 index 0000000000..59338d06ba --- /dev/null +++ b/components/edit/SlideNavRail/ThumbItem.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Reorder } from 'motion/react'; +import { MoreHorizontal } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { SceneThumbnailContent } from '@/components/stage/scene-thumbnail-content'; +import type { Scene } from '@/lib/types/stage'; +import { useCanvasStore } from '@/lib/store/canvas'; +import { useStageStore } from '@/lib/store/stage'; +import { useI18n } from '@/lib/hooks/use-i18n'; + +interface ThumbItemProps { + readonly scene: Scene; + readonly index: number; + readonly active: boolean; + readonly canDelete: boolean; + readonly onActivate: () => void; + readonly onDuplicate: () => void; + readonly onDelete: () => void; +} + +function ThumbItemComponent({ + scene, + index, + active, + canDelete, + onActivate, + onDuplicate, + onDelete, +}: ThumbItemProps) { + const { t } = useI18n(); + const viewportSize = useCanvasStore.use.viewportSize(); + const viewportRatio = useCanvasStore.use.viewportRatio(); + const updateScene = useStageStore.use.updateScene(); + const ref = useRef(null); + const visible = useNearViewport(ref); + + // Inline title-edit state. + const [renaming, setRenaming] = useState(false); + const [draft, setDraft] = useState(scene.title); + const inputRef = useRef(null); + + // Reset draft whenever scene title changes externally (e.g. another tab + // edited it, or duplicate appended a suffix). + useEffect(() => { + if (!renaming) setDraft(scene.title); + }, [scene.title, renaming]); + + const startRename = useCallback(() => { + setDraft(scene.title); + setRenaming(true); + // Focus + select on next tick so the input is mounted. + queueMicrotask(() => { + const el = inputRef.current; + if (el) { + el.focus(); + el.select(); + } + }); + }, [scene.title]); + + const commitRename = useCallback(() => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== scene.title) { + updateScene(scene.id, { title: trimmed }); + } else if (!trimmed) { + setDraft(scene.title); + } + setRenaming(false); + }, [draft, scene.id, scene.title, updateScene]); + + const cancelRename = useCallback(() => { + setDraft(scene.title); + setRenaming(false); + }, [scene.title]); + + return ( + +
{ + if (renaming) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onActivate(); + } + }} + // Matches playback `SceneSidebar` tile family — index badge + + // title header row above an aspect-video thumbnail card, whole + // tile flipped to violet-50 + ring when active. Differences from + // playback: inline title edit via the more-actions menu, and a + // hover-revealed three-dot menu (the only editor affordance + // overlaid on the playback shape). + className={cn( + 'group/thumb relative flex cursor-pointer select-none flex-col gap-1 rounded-lg p-1.5', + 'outline-none transition-colors duration-150', + active + ? 'bg-violet-50 ring-1 ring-violet-200 dark:bg-violet-900/20 dark:ring-violet-700' + : 'hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50', + )} + > + {/* Scene header — index badge + title. Title doubles as the + inline rename surface when `renaming` is true. */} +
+
+ + {index + 1} + + {renaming ? ( + setDraft(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + commitRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelRename(); + } + }} + onBlur={commitRename} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + aria-label={t('edit.nav.rename')} + className={cn( + 'min-w-0 flex-1 truncate rounded-sm bg-white px-1 py-0 text-xs font-bold outline-none', + 'ring-1 ring-violet-400 focus:ring-violet-500', + 'text-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-violet-500', + )} + /> + ) : ( + { + e.stopPropagation(); + startRename(); + }} + className={cn( + 'truncate text-xs font-bold transition-colors', + active + ? 'text-violet-700 dark:text-violet-300' + : 'text-zinc-600 group-hover/thumb:text-zinc-900 dark:text-zinc-300 dark:group-hover/thumb:text-zinc-100', + )} + title={scene.title} + > + {scene.title || `${t('edit.sceneType.' + scene.type)} ${index + 1}`} + + )} +
+ + {/* Three-dot overflow menu — hover-revealed, always visible + while open. Hidden during inline rename so the input has + the full header row. */} + {!renaming && ( + + + + + e.stopPropagation()} + > + {t('edit.nav.rename')} + + {t('edit.nav.duplicate')} + + + {t('edit.nav.delete')} + + + + )} +
+ + {/* Thumbnail card — same shape as playback SceneSidebar's tile. */} +
+
+ +
+
+
+
+ ); +} + +/** + * One tile in the Pro mode rail. Visual structure deliberately mirrors + * playback `SceneSidebar` — index badge + title row above an aspect- + * video thumbnail card, whole tile rounded with a violet background + + * ring when active — so the two sidebars read as the same component + * family across mode toggle. Editor-only additions: hover-revealed + * three-dot menu (Rename / Duplicate / Delete) and inline title rename + * (also reachable via double-click on the title text). + * + * All scene types are first-class — slides render a live + * `ThumbnailSlide`, non-slide scenes render the same stylised mockups + * playback's `SceneSidebar` uses. EditShell renders non-slide scenes + * read-only inside Pro mode (no auto-exit on click). + */ +export const ThumbItem = memo(ThumbItemComponent); + +/** + * Cheap "near viewport" IntersectionObserver so off-screen thumbs + * skip the live ThumbnailSlide render (which mounts a downscaled + * slide-renderer scene). Items within 200px of the viewport remain + * eager so scrolling feels instant. + */ +function useNearViewport(ref: React.RefObject) { + const [visible, setVisible] = useState(true); + useEffect(() => { + const el = ref.current; + if (!el || typeof IntersectionObserver === 'undefined') return; + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) setVisible(e.isIntersecting); + }, + { root: null, rootMargin: '200px 0px', threshold: 0 }, + ); + io.observe(el); + return () => io.disconnect(); + }, [ref]); + return visible; +} diff --git a/components/edit/SlideNavRail/index.ts b/components/edit/SlideNavRail/index.ts new file mode 100644 index 0000000000..9d193c84db --- /dev/null +++ b/components/edit/SlideNavRail/index.ts @@ -0,0 +1 @@ +export { SlideNavRail } from './SlideNavRail'; diff --git a/components/header.tsx b/components/header.tsx index 50dcf1ee0e..32c2cdca47 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -1,30 +1,16 @@ 'use client'; -import { - Settings, - Sun, - Moon, - Monitor, - ArrowLeft, - Loader2, - Download, - FileDown, - Package, - Archive, -} from 'lucide-react'; -import { Switch } from '@/components/ui/switch'; +import { ArrowLeft, Loader2, Download, FileDown, Package, Archive } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; -import { useTheme } from '@/lib/hooks/use-theme'; -import { LanguageSwitcher } from './language-switcher'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { SettingsDialog } from './settings'; import { cn } from '@/lib/utils'; import { useStageStore } from '@/lib/store/stage'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { useExportPPTX } from '@/lib/export/use-export-pptx'; import { useExportClassroom } from '@/lib/export/use-export-classroom'; import type { StageMode } from '@/lib/types/stage'; +import { HeaderControls } from './stage/header-controls'; interface HeaderProps { readonly currentSceneTitle: string; @@ -35,10 +21,7 @@ interface HeaderProps { export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: HeaderProps) { const { t } = useI18n(); - const { theme, setTheme } = useTheme(); const router = useRouter(); - const [settingsOpen, setSettingsOpen] = useState(false); - const [themeOpen, setThemeOpen] = useState(false); // Export const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); @@ -56,27 +39,22 @@ export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: H failedOutlines.length === 0 && Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed'); - const themeRef = useRef(null); - - // Close dropdown when clicking outside + // Close export dropdown when clicking outside (lang/theme/settings + // dropdowns live inside HeaderControls and self-manage there). const handleClickOutside = useCallback( (e: MouseEvent) => { - if (themeOpen && themeRef.current && !themeRef.current.contains(e.target as Node)) { - setThemeOpen(false); - } if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) { setExportMenuOpen(false); } }, - [themeOpen, exportMenuOpen], + [exportMenuOpen], ); useEffect(() => { - if (themeOpen || exportMenuOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [themeOpen, exportMenuOpen, handleClickOutside]); + if (!exportMenuOpen) return; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [exportMenuOpen, handleClickOutside]); return ( <> @@ -89,131 +67,26 @@ export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: H > -
- - {t('stage.currentScene')} - -

- {currentSceneTitle || t('common.loading')} -

-
- - -
- {/* Language Selector */} - setThemeOpen(false)} /> - - {/* Theme Selector */} -
- - {themeOpen && ( -
- - - -
- )} -
- - {/* Settings Button */} -
- -
+ {/* Title block — hidden in Pro mode (CommandBar shows the + scene title down below; double-stacking that title was the + "层叠割裂" complaint). The back button + right-side pill + + Pro Switch stay visible in both modes. */} + {mode !== 'edit' && ( +
+ + {t('stage.currentScene')} + +

+ {currentSceneTitle || t('common.loading')} +

+
+ )}
- {/* Pro Mode (edit) toggle — caps label + switch. Surfaces only the - two i18n strings already shipped in stage.editCourse/doneEditing - from #561; consumer code lives behind the optional onToggleEditMode - prop so embedders without an edit affordance render the same header. */} - {onToggleEditMode && ( - - )} + {/* Export Dropdown */}
@@ -289,7 +162,6 @@ export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: H )}
- ); } diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index 684397ad04..4b561560df 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -1,64 +1,55 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { supportedLocales } from '@/lib/i18n'; import { cn } from '@/lib/utils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; interface LanguageSwitcherProps { - /** Called when the dropdown opens, so parent can close sibling dropdowns */ + /** Called when the dropdown opens, so parent can close sibling dropdowns. */ onOpen?: () => void; } +/** + * Locale picker pill. Backed by Radix DropdownMenu so its content is + * portaled to `document.body` — important inside Pro mode's CommandBar + * (which lives under an `overflow-hidden` canvas slot that would + * otherwise clip the dropdown). + */ export function LanguageSwitcher({ onOpen }: LanguageSwitcherProps) { const { locale, setLocale } = useI18n(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - // Close on click outside - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [open]); return ( -
- - {open && ( -
- {supportedLocales.map((l) => ( - - ))} -
- )} -
+ { + if (open) onOpen?.(); + }} + > + + + + + {supportedLocales.map((l) => ( + setLocale(l.code)} + className={cn( + 'cursor-pointer', + locale === l.code && + 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', + )} + > + {l.label} + + ))} + + ); } diff --git a/components/slide-renderer/components/ThumbnailSlide/index.tsx b/components/slide-renderer/components/ThumbnailSlide/index.tsx index 18442a9b33..0252fe3b43 100644 --- a/components/slide-renderer/components/ThumbnailSlide/index.tsx +++ b/components/slide-renderer/components/ThumbnailSlide/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import type { Slide } from '@/lib/types/slides'; import { useSlideBackgroundStyle } from '@/lib/hooks/use-slide-background-style'; import { ThumbnailElement } from './ThumbnailElement'; @@ -6,8 +6,15 @@ import { ThumbnailElement } from './ThumbnailElement'; interface ThumbnailSlideProps { /** Slide data */ readonly slide: Slide; - /** Thumbnail width */ - readonly size: number; + /** + * Thumbnail width. When omitted, the thumbnail self-measures and fills its + * parent's clientWidth via ResizeObserver. Use auto-size in any container + * that already constrains width via CSS (e.g. `aspect-video w-full`) — this + * removes the double-signal problem where a JS-computed size prop is + * recomputed every pointer-tick while a CSS-driven outer container + * concurrently reflows. + */ + readonly size?: number; /** Viewport width base (default 1000px) */ readonly viewportSize: number; /** Viewport aspect ratio (default 0.5625 i.e. 16:9) */ @@ -19,8 +26,17 @@ interface ThumbnailSlideProps { /** * Thumbnail slide component * - * Renders a thumbnail preview of a single slide - * Uses CSS transform scale to resize the entire view for better performance + * Renders a thumbnail preview of a single slide. Uses CSS transform scale to + * resize the entire view for better performance. + * + * Sizing modes: + * - **Explicit (`size` prop)**: outer card is sized to `size × size*ratio` px. + * Used by playback `SceneSidebar` and `app/page.tsx` outline preview. + * - **Auto (no `size` prop)**: outer card fills its parent (`w-full h-full`) + * and the internal scale is computed from `ResizeObserver(self.clientWidth)`. + * Used by the editor `SlideNavRail` ThumbItem, which sits inside an + * `aspect-video w-full` shell and must not depend on a JS-computed size + * that re-renders every pointer-tick during rail drag. */ export function ThumbnailSlide({ slide, @@ -29,21 +45,44 @@ export function ThumbnailSlide({ viewportRatio, visible = true, }: ThumbnailSlideProps) { - // Calculate scale ratio - const scale = useMemo(() => size / viewportSize, [size, viewportSize]); + const autoSize = size === undefined; + const containerRef = useRef(null); + const [observedWidth, setObservedWidth] = useState(0); + + useLayoutEffect(() => { + if (!autoSize) return; + const el = containerRef.current; + if (!el) return; + const measure = () => { + const w = el.clientWidth; + // Avoid React state thrash when the box settles on an identical width. + setObservedWidth((prev) => (prev === w ? prev : w)); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [autoSize]); + + const effectiveWidth = autoSize ? observedWidth : (size ?? 0); + const scale = effectiveWidth > 0 ? effectiveWidth / viewportSize : 0; // Get background style const { backgroundStyle } = useSlideBackgroundStyle(slide.background); + // In auto mode the outer container is CSS-sized (full parent) so any + // animated outer width from the parent is the single source of truth; + // we just observe it. In explicit mode we paint a fixed pixel box. + const containerClass = autoSize + ? 'thumbnail-slide relative bg-white overflow-hidden select-none pointer-events-none w-full h-full' + : 'thumbnail-slide bg-white overflow-hidden select-none pointer-events-none'; + const containerStyle: React.CSSProperties | undefined = autoSize + ? undefined + : { width: `${size}px`, height: `${(size ?? 0) * viewportRatio}px` }; + if (!visible) { return ( -
+
加载中 ...
@@ -52,13 +91,7 @@ export function ThumbnailSlide({ } return ( -
+
void; + /** + * `default` — the chunky h-9 pill used in the playback Stage Header. + * `compact` — slightly tighter padding for embedding in CommandBar's + * right slot (Pro mode chrome already eats height, so the pill backs + * off ring weight / blur to keep the CommandBar quiet). + */ + readonly variant?: 'default' | 'compact'; +} + +/** + * Stage-level global controls: language picker, theme picker, settings + * modal trigger, and the Pro Switch. Extracted out of `Header` so the + * Pro mode CommandBar can absorb the same affordances and the playback + * Header doesn't need to stay mounted just to host them — Pro mode + * therefore lands on a single top-chrome bar instead of stacking the + * Stage Header above the EditShell CommandBar. + * + * Only one instance is ever mounted at a time (Stage renders Header + * for playback and EditShell.CommandBar's trailing slot for edit, but + * never both), so dropdown / dialog state and refs stay co-located + * here without cross-instance leakage. + */ +export function HeaderControls({ + mode, + canEdit, + onToggleEditMode, + variant = 'default', +}: HeaderControlsProps) { + const { t } = useI18n(); + const { theme, setTheme } = useTheme(); + const [settingsOpen, setSettingsOpen] = useState(false); + + const compact = variant === 'compact'; + + return ( + <> +
+ {/* Language — Radix DropdownMenu so its menu portals to body + and never gets clipped by an ancestor's overflow-hidden. */} + + + {/* Theme — same Portal-backed DropdownMenu pattern. */} + + + + + + setTheme('light')} + className={cn( + 'cursor-pointer gap-2', + theme === 'light' && + 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', + )} + > + + {t('settings.themeOptions.light')} + + setTheme('dark')} + className={cn( + 'cursor-pointer gap-2', + theme === 'dark' && + 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', + )} + > + + {t('settings.themeOptions.dark')} + + setTheme('system')} + className={cn( + 'cursor-pointer gap-2', + theme === 'system' && + 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', + )} + > + + {t('settings.themeOptions.system')} + + + + + {/* Settings */} + +
+ + {/* Pro Switch — toggle property: on/off both clickable, not a + one-way "Done" button. Disabled only when the current scene + can't be entered (pending/generating/etc.). */} + {onToggleEditMode && ( + + )} + + + + ); +} diff --git a/components/stage/scene-thumbnail-content.tsx b/components/stage/scene-thumbnail-content.tsx new file mode 100644 index 0000000000..079b098143 --- /dev/null +++ b/components/stage/scene-thumbnail-content.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { BookOpen, Globe } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide'; +import { ThumbnailInteractive } from '@/components/slide-renderer/components/ThumbnailInteractive'; +import type { Scene, SlideContent, InteractiveContent } from '@/lib/types/stage'; + +interface SceneThumbnailContentProps { + readonly scene: Scene; + /** + * Inner thumbnail pixel size (width) for ThumbnailInteractive's iframe + * scaling. Optional — when omitted, ThumbnailInteractive falls back to + * a fixed default. The slide branch ignores `size` entirely and always + * uses auto-measure (ResizeObserver on the parent container), so editor + * rail drag never threads a per-frame pixel width through this prop. + */ + readonly size?: number; + readonly viewportSize: number; + readonly viewportRatio: number; + /** Skip the live live-render path (slide + interactive iframe) when + * the tile is far off-screen. */ + readonly visible?: boolean; +} + +const INTERACTIVE_FALLBACK_SIZE = 200; + +/** + * Shared per-scene-type thumbnail render — slide gets a live + * ThumbnailSlide, quiz/interactive/pbl get the same stylized mockups + * that ship in playback SceneSidebar. Extracted so editor ThumbItem + * and playback SceneSidebar render identical content (the user + * specifically asked for parity instead of the previous icon-only stub + * in Pro mode). + * + * Caller is responsible for the outer aspect-video card + ring; this + * component only paints the inner content centered to fill. + */ +export function SceneThumbnailContent({ + scene, + size, + viewportSize, + viewportRatio, + visible = true, +}: SceneThumbnailContentProps) { + if (scene.type === 'slide') { + const slideContent = scene.content as SlideContent; + return ( + + ); + } + + if (scene.type === 'quiz') { + return ( +
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (scene.type === 'interactive') { + const interactiveContent = scene.content as InteractiveContent; + if (interactiveContent.html && visible) { + return ( + + ); + } + return ( +
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ +
+
+
+ ); + } + + if (scene.type === 'pbl') { + return ( +
+
+
+
+
+
+ {[0, 1, 2].map((col) => ( +
+
+ {Array.from({ length: col === 0 ? 3 : col === 1 ? 2 : 1 }).map((_, i) => ( +
+ ))} +
+ ))} +
+
+ ); + } + + // Exhaustive guard — Scene's `type` union is fully handled above. + // The fallback only fires for forward-compat scenarios where a new + // scene type is loaded by an older client. + const unknownType = (scene as { type: string }).type; + return ( +
+ + + {unknownType} + +
+ ); +} diff --git a/lib/edit/deleted-scene-recycle.ts b/lib/edit/deleted-scene-recycle.ts new file mode 100644 index 0000000000..ece12e4501 --- /dev/null +++ b/lib/edit/deleted-scene-recycle.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import type { Scene } from '@/lib/types/stage'; + +/** + * Single-slot recycle bin for the Pro mode slide-nav rail's toast-undo. + * + * When the user deletes a slide from the rail, the deleted scene + its + * original array index are pushed here so a "Undo" affordance in the + * delete toast can restore the scene at its prior position. The slot + * holds at most one entry — a subsequent delete evicts the previous + * pending undo (matching Figma's recycle semantics). + * + * Restoring the scene happens at the call site by re-inserting it into + * `useStageStore.scenes` at the recorded index; this store only owns + * the snapshot, not the restoration logic. + */ + +interface RecycleEntry { + readonly scene: Scene; + readonly index: number; + /** Cleared if the auto-dismiss timer has already fired. */ + readonly stageId: string; +} + +interface DeletedSceneRecycleState { + pending: RecycleEntry | null; + capture: (scene: Scene, index: number) => void; + consume: () => RecycleEntry | null; + clear: () => void; +} + +export const useDeletedSceneRecycle = create()((set, get) => ({ + pending: null, + capture: (scene, index) => set({ pending: { scene, index, stageId: scene.stageId } }), + consume: () => { + const entry = get().pending; + if (entry) set({ pending: null }); + return entry; + }, + clear: () => set({ pending: null }), +})); diff --git a/lib/edit/slide-defaults.ts b/lib/edit/slide-defaults.ts new file mode 100644 index 0000000000..92991276ef --- /dev/null +++ b/lib/edit/slide-defaults.ts @@ -0,0 +1,89 @@ +import { nanoid } from 'nanoid'; +import type { Slide, SlideTheme, PPTElement } from '@/lib/types/slides'; +import type { Scene, SlideContent } from '@/lib/types/stage'; +import { createElementId } from '@/lib/edit/element-id'; +import { CURRENT_SLIDE_CONTENT_SCHEMA_VERSION } from '@/lib/edit/slide-schema'; + +const DEFAULT_THEME: SlideTheme = { + backgroundColor: '#ffffff', + themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'], + fontColor: '#333333', + fontName: 'Microsoft YaHei', + outline: { color: '#d14424', width: 2, style: 'solid' }, + shadow: { h: 0, v: 0, blur: 10, color: '#000000' }, +}; + +/** + * Build a fresh blank slide scene for `+ Add slide` in the Pro mode rail. + * Matches the SceneBuilder default theme so user-added slides look the + * same as AI-generated ones until customized. + */ +export function createBlankSlideScene(stageId: string, title: string, order: number): Scene { + const slide: Slide = { + id: nanoid(), + viewportSize: 1000, + viewportRatio: 0.5625, + theme: DEFAULT_THEME, + elements: [], + background: { type: 'solid', color: '#ffffff' }, + }; + + const content: SlideContent = { + type: 'slide', + schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION, + canvas: slide, + }; + + return { + id: nanoid(), + stageId, + type: 'slide', + title, + order, + content, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +/** + * Build a duplicate of an existing slide scene. Deep-clones the slide + * payload and reassigns every element id so React keys + downstream + * selection state can't collide with the source slide. The new scene + * gets a fresh scene id; caller is responsible for placing it in the + * scenes array (via `insertSceneAfter`). + */ +export function duplicateSlideScene(source: Scene, copySuffix: string, order: number): Scene { + if (source.type !== 'slide') { + throw new Error('duplicateSlideScene: source scene is not a slide'); + } + const sourceContent = source.content as SlideContent; + const clonedElements: PPTElement[] = sourceContent.canvas.elements.map((element) => ({ + ...element, + id: createElementId(element.type), + })); + + const clonedSlide: Slide = { + ...sourceContent.canvas, + id: nanoid(), + elements: clonedElements, + }; + + const content: SlideContent = { + ...sourceContent, + schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION, + canvas: clonedSlide, + }; + + const title = copySuffix ? `${source.title} ${copySuffix}` : source.title; + + return { + ...source, + id: nanoid(), + title, + order, + content, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} diff --git a/lib/edit/transitions.ts b/lib/edit/transitions.ts new file mode 100644 index 0000000000..86b2f64218 --- /dev/null +++ b/lib/edit/transitions.ts @@ -0,0 +1,29 @@ +/** + * Shared easing / duration constants for the Pro mode chrome transitions. + * + * Centralized so the choreography between stage.tsx (sidebar slide-out, + * canvas slot motion.layout) and EditShell (CommandBar drop, leftRail + * slide-in, content fade) shares one timing source. + * + * Ease curve [0.22, 1, 0.36, 1] is the cubic-bezier ease-out-quart shape — + * natural deceleration with a slight overshoot at the end, used elsewhere + * in the OpenMAIC playback chrome. + */ +export const CHROME_EASE = [0.22, 1, 0.36, 1] as const; + +/** Base duration for a chrome enter/exit step (seconds). */ +export const CHROME_DURATION = 0.28; + +/** Same duration in milliseconds (for CSS `transition` strings). */ +export const CHROME_DURATION_MS = Math.round(CHROME_DURATION * 1000); + +/** Ease curve as a CSS `cubic-bezier(...)` string for CSS `transition`. */ +export const CHROME_EASE_CSS = `cubic-bezier(${CHROME_EASE.join(', ')})`; + +/** Inter-element stagger between chrome layers (seconds). */ +export const CHROME_STAGGER = 0.1; + +export const CHROME_TRANSITION = { + duration: CHROME_DURATION, + ease: CHROME_EASE, +} as const; diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index d686f8bb73..5492261d1b 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -155,6 +155,7 @@ "delete": "حذف", "title": "تحرير · {{type}}", "unsupportedScene": "{{type}} غير قابل للتحرير بعد", + "readOnlyBadge": "{{type}} · للعرض فقط", "sceneType": { "slide": "شريحة", "quiz": "اختبار", @@ -191,6 +192,21 @@ "imageOr": "أو الصق رابط صورة", "imageUrlPlaceholder": "https://…", "imageInsert": "إدراج" + }, + "nav": { + "addSlide": "إضافة شريحة", + "duplicate": "تكرار", + "delete": "حذف", + "deleted": "تم الحذف", + "undo": "تراجع", + "copySuffix": "(نسخة)", + "untitledSlide": "شريحة بدون عنوان", + "collapse": "طي الشريط الجانبي", + "expand": "توسيع الشريط الجانبي", + "dragHandle": "اسحب لإعادة الترتيب", + "deckLabel": "المشاهد", + "moreActions": "إجراءات إضافية", + "rename": "إعادة تسمية" } }, "classroomComplete": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index cb414a7171..b9c384f302 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -155,6 +155,7 @@ "delete": "Delete", "title": "Editing · {{type}}", "unsupportedScene": "{{type}} is not editable yet", + "readOnlyBadge": "{{type}} · view-only", "sceneType": { "slide": "Slide", "quiz": "Quiz", @@ -191,6 +192,21 @@ "imageOr": "or paste an image URL", "imageUrlPlaceholder": "https://…", "imageInsert": "Insert" + }, + "nav": { + "addSlide": "Add slide", + "duplicate": "Duplicate", + "delete": "Delete", + "deleted": "Deleted", + "undo": "Undo", + "copySuffix": "(copy)", + "untitledSlide": "Untitled slide", + "collapse": "Collapse rail", + "expand": "Expand rail", + "dragHandle": "Drag to reorder", + "deckLabel": "Scenes", + "moreActions": "More actions", + "rename": "Rename" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 920750d884..cf49afa111 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -155,6 +155,7 @@ "delete": "削除", "title": "編集 · {{type}}", "unsupportedScene": "{{type}}はまだ編集できません", + "readOnlyBadge": "{{type}} · 表示のみ", "sceneType": { "slide": "スライド", "quiz": "クイズ", @@ -191,6 +192,21 @@ "imageOr": "または画像 URL を貼り付け", "imageUrlPlaceholder": "https://…", "imageInsert": "挿入" + }, + "nav": { + "addSlide": "スライドを追加", + "duplicate": "複製", + "delete": "削除", + "deleted": "削除しました", + "undo": "元に戻す", + "copySuffix": "(コピー)", + "untitledSlide": "無題のスライド", + "collapse": "サイドを折りたたむ", + "expand": "サイドを開く", + "dragHandle": "ドラッグして並べ替え", + "deckLabel": "シーン", + "moreActions": "その他の操作", + "rename": "名前を変更" } }, "classroomComplete": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 0a9648d06e..f588487dde 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -155,6 +155,7 @@ "delete": "Удалить", "title": "Редактирование · {{type}}", "unsupportedScene": "{{type}} пока нельзя редактировать", + "readOnlyBadge": "{{type}} · только просмотр", "sceneType": { "slide": "Слайд", "quiz": "Тест", @@ -191,6 +192,21 @@ "imageOr": "или вставьте URL изображения", "imageUrlPlaceholder": "https://…", "imageInsert": "Вставить" + }, + "nav": { + "addSlide": "Добавить слайд", + "duplicate": "Дублировать", + "delete": "Удалить", + "deleted": "Удалено", + "undo": "Отменить", + "copySuffix": "(копия)", + "untitledSlide": "Безымянный слайд", + "collapse": "Свернуть панель", + "expand": "Развернуть панель", + "dragHandle": "Перетащите, чтобы переупорядочить", + "deckLabel": "Сцены", + "moreActions": "Ещё действия", + "rename": "Переименовать" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 4b4cfb4f99..0b13080b39 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -155,6 +155,7 @@ "delete": "删除", "title": "编辑 · {{type}}", "unsupportedScene": "{{type}}暂不支持编辑", + "readOnlyBadge": "{{type}} · 仅查看", "sceneType": { "slide": "幻灯片", "quiz": "测验", @@ -191,6 +192,21 @@ "imageOr": "或粘贴图片 URL", "imageUrlPlaceholder": "https://…", "imageInsert": "插入" + }, + "nav": { + "addSlide": "新增幻灯片", + "duplicate": "复制", + "delete": "删除", + "deleted": "已删除", + "undo": "撤销", + "copySuffix": "(副本)", + "untitledSlide": "未命名幻灯片", + "collapse": "收起", + "expand": "展开", + "dragHandle": "拖动", + "deckLabel": "场景", + "moreActions": "更多操作", + "rename": "重命名" } }, "classroomComplete": { diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 678f56f8cb..3eedf4376d 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -155,6 +155,7 @@ "delete": "刪除", "title": "編輯 · {{type}}", "unsupportedScene": "{{type}}暫不支援編輯", + "readOnlyBadge": "{{type}} · 僅檢視", "sceneType": { "slide": "投影片", "quiz": "測驗", @@ -191,6 +192,21 @@ "imageOr": "或貼上圖片網址", "imageUrlPlaceholder": "https://…", "imageInsert": "插入" + }, + "nav": { + "addSlide": "新增投影片", + "duplicate": "複製", + "delete": "刪除", + "deleted": "已刪除", + "undo": "復原", + "copySuffix": "(副本)", + "untitledSlide": "未命名投影片", + "collapse": "收合側欄", + "expand": "展開側欄", + "dragHandle": "拖曳重新排序", + "deckLabel": "場景", + "moreActions": "更多操作", + "rename": "重新命名" } }, "whiteboard": { diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 92970671c6..54f17004f5 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -193,6 +193,8 @@ export interface SettingsState { sidebarCollapsed: boolean; chatAreaCollapsed: boolean; chatAreaWidth: number; + editRailCollapsed: boolean; + editRailWidth: number; // Actions setModel: (providerId: ProviderId, modelId: string) => void; @@ -217,6 +219,8 @@ export interface SettingsState { setSidebarCollapsed: (collapsed: boolean) => void; setChatAreaCollapsed: (collapsed: boolean) => void; setChatAreaWidth: (width: number) => void; + setEditRailCollapsed: (collapsed: boolean) => void; + setEditRailWidth: (width: number) => void; // Audio actions setTTSProvider: (providerId: TTSProviderId) => void; @@ -720,6 +724,8 @@ export const useSettingsStore = create()( sidebarCollapsed: true, chatAreaCollapsed: true, chatAreaWidth: 320, + editRailCollapsed: false, + editRailWidth: 220, // Audio settings (use defaults) ...defaultAudioConfig, @@ -802,6 +808,8 @@ export const useSettingsStore = create()( // Layout actions setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), setChatAreaCollapsed: (collapsed) => set({ chatAreaCollapsed: collapsed }), + setEditRailCollapsed: (collapsed) => set({ editRailCollapsed: collapsed }), + setEditRailWidth: (width) => set({ editRailWidth: width }), setChatAreaWidth: (width) => set({ chatAreaWidth: width }), // Audio actions diff --git a/lib/store/stage.ts b/lib/store/stage.ts index 66bfd7b45e..0917132897 100644 --- a/lib/store/stage.ts +++ b/lib/store/stage.ts @@ -71,6 +71,7 @@ interface StageState { setStage: (stage: Stage) => void; setScenes: (scenes: Scene[]) => void; addScene: (scene: Scene) => void; + insertSceneAfter: (anchorSceneId: string, scene: Scene) => void; updateScene: (sceneId: string, updates: Partial) => void; deleteScene: (sceneId: string) => void; setCurrentSceneId: (sceneId: string | null) => void; @@ -159,6 +160,28 @@ const useStageStoreBase = create()((set, get) => ({ debouncedSave(); }, + insertSceneAfter: (anchorSceneId, scene) => { + // Pro mode slide management entry point — inserts after the anchor and + // rebalances `order` so PPTX export / array position stay consistent. + // Edit mode is gated against active regeneration (see useEditModeLock), + // so rewriting `order` here is safe — no outline matcher is racing us. + const currentStage = get().stage; + if (!currentStage || scene.stageId !== currentStage.id) { + log.warn( + `insertSceneAfter ignored "${scene.title}" - stageId mismatch (scene: ${scene.stageId}, current: ${currentStage?.id})`, + ); + return; + } + const current = get().scenes; + const anchorIndex = current.findIndex((s) => s.id === anchorSceneId); + const insertIndex = anchorIndex < 0 ? current.length : anchorIndex + 1; + const migrated = migrateScene(scene); + const next = [...current.slice(0, insertIndex), migrated, ...current.slice(insertIndex)]; + const rebalanced = next.map((s, i) => (s.order === i + 1 ? s : { ...s, order: i + 1 })); + set({ scenes: rebalanced }); + debouncedSave(); + }, + updateScene: (sceneId, updates) => { const scenes = get().scenes.map((scene) => scene.id === sceneId ? { ...scene, ...updates } : scene, diff --git a/tests/edit/deleted-scene-recycle.test.ts b/tests/edit/deleted-scene-recycle.test.ts new file mode 100644 index 0000000000..5265bd3899 --- /dev/null +++ b/tests/edit/deleted-scene-recycle.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { useDeletedSceneRecycle } from '@/lib/edit/deleted-scene-recycle'; +import type { Scene } from '@/lib/types/stage'; + +function makeScene(id: string, stageId = 'stage-1'): Scene { + return { + id, + stageId, + type: 'slide', + title: 'T', + order: 1, + content: { + type: 'slide', + canvas: { + id: 'c', + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + backgroundColor: '#fff', + themeColors: ['#000'], + fontColor: '#000', + fontName: 'Inter', + }, + elements: [], + }, + }, + }; +} + +afterEach(() => { + useDeletedSceneRecycle.getState().clear(); +}); + +describe('useDeletedSceneRecycle', () => { + it('captures a scene with its original index', () => { + const s = makeScene('a'); + useDeletedSceneRecycle.getState().capture(s, 3); + const pending = useDeletedSceneRecycle.getState().pending; + expect(pending?.scene).toBe(s); + expect(pending?.index).toBe(3); + expect(pending?.stageId).toBe('stage-1'); + }); + + it('consume returns the entry and clears the slot', () => { + const s = makeScene('a'); + useDeletedSceneRecycle.getState().capture(s, 2); + const entry = useDeletedSceneRecycle.getState().consume(); + expect(entry?.scene).toBe(s); + expect(useDeletedSceneRecycle.getState().pending).toBeNull(); + }); + + it('consume returns null when no entry is pending', () => { + expect(useDeletedSceneRecycle.getState().consume()).toBeNull(); + }); + + it('a second capture evicts the previous pending entry', () => { + const first = makeScene('a'); + const second = makeScene('b'); + useDeletedSceneRecycle.getState().capture(first, 0); + useDeletedSceneRecycle.getState().capture(second, 5); + const pending = useDeletedSceneRecycle.getState().pending; + expect(pending?.scene).toBe(second); + expect(pending?.index).toBe(5); + }); + + it('clear empties the slot without returning anything', () => { + useDeletedSceneRecycle.getState().capture(makeScene('a'), 0); + useDeletedSceneRecycle.getState().clear(); + expect(useDeletedSceneRecycle.getState().pending).toBeNull(); + }); +}); diff --git a/tests/edit/slide-defaults.test.ts b/tests/edit/slide-defaults.test.ts new file mode 100644 index 0000000000..34909efba6 --- /dev/null +++ b/tests/edit/slide-defaults.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { createBlankSlideScene, duplicateSlideScene } from '@/lib/edit/slide-defaults'; +import { CURRENT_SLIDE_CONTENT_SCHEMA_VERSION } from '@/lib/edit/slide-schema'; +import type { Scene, SlideContent } from '@/lib/types/stage'; +import type { PPTTextElement } from '@/lib/types/slides'; + +function makeTextEl(id: string): PPTTextElement { + return { + type: 'text', + id, + left: 0, + top: 0, + width: 200, + height: 80, + rotate: 0, + defaultColor: '#000', + defaultFontName: 'Inter', + lineHeight: 1.2, + content: '

x

', + }; +} + +function makeSlideScene(overrides: Partial = {}): Scene { + const slideContent: SlideContent = { + type: 'slide', + schemaVersion: CURRENT_SLIDE_CONTENT_SCHEMA_VERSION, + canvas: { + id: 'slide-1', + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + backgroundColor: '#fff', + themeColors: ['#000'], + fontColor: '#000', + fontName: 'Inter', + }, + elements: [makeTextEl('el-a'), makeTextEl('el-b')], + background: { type: 'solid', color: '#ffffff' }, + }, + }; + return { + id: 'scene-source', + stageId: 'stage-1', + type: 'slide', + title: 'Source slide', + order: 1, + content: slideContent, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe('createBlankSlideScene', () => { + it('produces a slide scene with the current schema version', () => { + const s = createBlankSlideScene('stage-1', 'Untitled', 1); + expect(s.type).toBe('slide'); + expect(s.stageId).toBe('stage-1'); + expect(s.title).toBe('Untitled'); + if (s.content.type !== 'slide') throw new Error('expected slide content'); + expect(s.content.schemaVersion).toBe(CURRENT_SLIDE_CONTENT_SCHEMA_VERSION); + expect(s.content.canvas.elements).toEqual([]); + expect(s.content.canvas.background?.type).toBe('solid'); + }); + + it('mints a fresh scene id + slide id on every call', () => { + const a = createBlankSlideScene('stage-1', 'A', 1); + const b = createBlankSlideScene('stage-1', 'B', 2); + expect(a.id).not.toBe(b.id); + if (a.content.type !== 'slide' || b.content.type !== 'slide') { + throw new Error('expected slide content'); + } + expect(a.content.canvas.id).not.toBe(b.content.canvas.id); + }); +}); + +describe('duplicateSlideScene', () => { + it('returns a deep-cloned slide with new scene id + new slide id', () => { + const source = makeSlideScene(); + const dup = duplicateSlideScene(source, '(copy)', 2); + expect(dup.id).not.toBe(source.id); + if (source.content.type !== 'slide' || dup.content.type !== 'slide') { + throw new Error('expected slide content'); + } + expect(dup.content.canvas.id).not.toBe(source.content.canvas.id); + expect(dup.content).not.toBe(source.content); + expect(dup.content.canvas).not.toBe(source.content.canvas); + }); + + it('reassigns every element id so React keys cannot collide', () => { + const source = makeSlideScene(); + const dup = duplicateSlideScene(source, '(copy)', 2); + if (source.content.type !== 'slide' || dup.content.type !== 'slide') { + throw new Error('expected slide content'); + } + const srcIds = source.content.canvas.elements.map((e) => e.id); + const dupIds = dup.content.canvas.elements.map((e) => e.id); + expect(dupIds).toHaveLength(srcIds.length); + for (const id of dupIds) { + expect(srcIds).not.toContain(id); + } + }); + + it('appends the copy suffix to the title', () => { + const source = makeSlideScene({ title: 'Hello' }); + const dup = duplicateSlideScene(source, '(copy)', 2); + expect(dup.title).toBe('Hello (copy)'); + }); + + it('passes title through unchanged when copy suffix is empty', () => { + const source = makeSlideScene({ title: 'Hello' }); + const dup = duplicateSlideScene(source, '', 2); + expect(dup.title).toBe('Hello'); + }); + + it('throws when the source is not a slide scene', () => { + const quiz: Scene = { + id: 'q', + stageId: 'stage-1', + type: 'quiz', + title: 'Quiz', + order: 1, + content: { type: 'quiz', questions: [] }, + }; + expect(() => duplicateSlideScene(quiz, '(copy)', 2)).toThrow(); + }); +}); diff --git a/tests/store/stage-insert-scene-after.test.ts b/tests/store/stage-insert-scene-after.test.ts new file mode 100644 index 0000000000..f7565f508b --- /dev/null +++ b/tests/store/stage-insert-scene-after.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// IndexedDB / stage-storage modules are imported dynamically inside the +// store's save/load actions. Mock them so the debounced save doesn't try +// to talk to a real (or jsdom) IndexedDB in the test environment. +vi.mock('@/lib/utils/stage-storage', () => ({ + saveStageData: vi.fn().mockResolvedValue(undefined), + loadStageData: vi.fn().mockResolvedValue(null), +})); +vi.mock('@/lib/utils/database', () => ({ + db: { stageOutlines: { put: vi.fn(), get: vi.fn() } }, +})); + +import { useStageStore } from '@/lib/store/stage'; +import type { Scene, Stage } from '@/lib/types/stage'; + +function makeStage(): Stage { + return { + id: 'stage-1', + name: 'Test stage', + createdAt: 1, + updatedAt: 1, + }; +} + +function makeSlideScene(id: string, order: number, stageId = 'stage-1'): Scene { + return { + id, + stageId, + type: 'slide', + title: id, + order, + content: { + type: 'slide', + canvas: { + id: `canvas-${id}`, + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + backgroundColor: '#fff', + themeColors: ['#000'], + fontColor: '#000', + fontName: 'Inter', + }, + elements: [], + }, + }, + }; +} + +beforeEach(() => { + useStageStore.setState({ + stage: makeStage(), + scenes: [makeSlideScene('a', 1), makeSlideScene('b', 2), makeSlideScene('c', 3)], + currentSceneId: 'a', + }); +}); + +afterEach(() => { + useStageStore.getState().clearStore(); +}); + +describe('insertSceneAfter', () => { + it('inserts after the anchor index', () => { + const fresh = makeSlideScene('x', 99); + useStageStore.getState().insertSceneAfter('a', fresh); + const ids = useStageStore.getState().scenes.map((s) => s.id); + expect(ids).toEqual(['a', 'x', 'b', 'c']); + }); + + it('rebalances order to monotonic 1-based after insert', () => { + const fresh = makeSlideScene('x', 999); + useStageStore.getState().insertSceneAfter('b', fresh); + const orders = useStageStore.getState().scenes.map((s) => s.order); + expect(orders).toEqual([1, 2, 3, 4]); + }); + + it('rejects a scene whose stageId mismatches the current stage', () => { + const foreign = makeSlideScene('z', 4, 'stage-9'); + useStageStore.getState().insertSceneAfter('a', foreign); + const ids = useStageStore.getState().scenes.map((s) => s.id); + expect(ids).toEqual(['a', 'b', 'c']); + }); + + it('falls through to append when the anchor is not found', () => { + const fresh = makeSlideScene('x', 7); + useStageStore.getState().insertSceneAfter('does-not-exist', fresh); + const ids = useStageStore.getState().scenes.map((s) => s.id); + expect(ids).toEqual(['a', 'b', 'c', 'x']); + }); + + it('does not switch currentSceneId — callers decide focus', () => { + const fresh = makeSlideScene('x', 99); + useStageStore.getState().insertSceneAfter('a', fresh); + expect(useStageStore.getState().currentSceneId).toBe('a'); + }); +}); From a02282e368a17e3f1c4233623e122287a25bcf74 Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 02:36:01 -0400 Subject: [PATCH 02/14] refactor(maic-editor): split Stage chrome into mode-specific roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug-driven architectural rework. Two symptoms motivated this: 1. Switching from a slide scene to a non-slide one (interactive / quiz / pbl) flickered the entire edit chrome — CommandBar and SlideNavRail remounted along with the canvas. Root cause: EditShell returned a different component type (EditShellWithSurface vs EditShellReadOnly) based on whether a SceneEditorSurface was registered for the scene type, so React reconciled the change as an unmount/remount of the whole subtree. 2. `components/stage.tsx` had grown to 1391 lines — playback engine state, chat / TTS / discussion wiring, presentation/fullscreen, keyboard handling, AND the edit-mode dispatcher all in one place. Any change to mode coordination meant touching this god component. Changes: - New `NOOP_SURFACE` (`lib/edit/noop-surface.tsx`) — a no-op SceneEditorSurface used as a fallback when a scene type has no registered editor surface. `SurfaceState.history` is now optional so read-only surfaces can omit undo/redo cleanly. EditShell falls back to NOOP for unregistered types. - EditShell now mounts a single Frame across all scene types. Surface state is published from a child `SurfaceStateRunner` keyed by `scene.type` (so it remounts only when the runner's hook signature changes — rules-of-hooks compliant), with a custom shallow equality so the chrome doesn't re-render every render cycle for reference-fresh state objects. Result: slide ↔ interactive no longer remounts the CommandBar or the leftRail. - `stage.tsx` → 113 lines. Mode dispatch + cross-tab edit-lock coordination + Pro-Switch toggle wiring + multi-tab conflict prompt only. Everything else moved into one of two new components: - `PlaybackChromeRoot` (`components/edit/PlaybackChromeRoot.tsx`): owns the entire playback / autonomous chrome — PlaybackEngine, chat, discussion TTS, presentation mode, keyboard shortcuts, SceneSidebar, Header, CanvasArea, Roundtable, ChatArea, AlertDialog. Exposes `teardown()` via forwardRef so the toggle can `await` SSE / engine / TTS shutdown before unmounting it. - `EditChromeRoot` (`components/edit/EditChromeRoot.tsx`): the Pro mode chrome wrapper — EditShell + SlideNavRail + HeaderControls trailing slot. Owns `body[data-maic-editor]` lifecycle (lifted from SlideCanvas so it covers read-only Pro-mode scene types too). - New `StageGrid` (`components/edit/StageGrid.tsx`) — CSS-Grid named- slot layout shell with top / left / center / right / bottom areas for the Pro mode chrome. Future right panel (properties / AI) and bottom timeline plug in as props with no structural code change. EditShell's Frame now uses StageGrid internally. - Deleted `components/edit/SlideTransitionBridge.tsx` (dead code from the original A3 transition plan that this rework supersedes). Co-Authored-By: Claude Opus 4.7 --- components/edit/EditChromeRoot.tsx | 64 + components/edit/EditShell/EditShell.tsx | 258 +++- components/edit/PlaybackChromeRoot.tsx | 1332 ++++++++++++++++ components/edit/StageGrid.tsx | 66 + .../edit/surfaces/slide/SlideCanvas.tsx | 20 +- components/stage.tsx | 1362 +---------------- lib/edit/noop-surface.tsx | 81 + lib/edit/scene-editor-surface.ts | 7 +- 8 files changed, 1798 insertions(+), 1392 deletions(-) create mode 100644 components/edit/EditChromeRoot.tsx create mode 100644 components/edit/PlaybackChromeRoot.tsx create mode 100644 components/edit/StageGrid.tsx create mode 100644 lib/edit/noop-surface.tsx diff --git a/components/edit/EditChromeRoot.tsx b/components/edit/EditChromeRoot.tsx new file mode 100644 index 0000000000..ab394e1c3e --- /dev/null +++ b/components/edit/EditChromeRoot.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useEffect } from 'react'; +import { EditShell } from '@/components/edit/EditShell'; +import { SlideNavRail } from '@/components/edit/SlideNavRail'; +import { HeaderControls } from '@/components/stage/header-controls'; +import { isMaicEditorEnabled } from '@/lib/config/feature-flags'; +import type { Scene } from '@/lib/types/stage'; + +interface EditChromeRootProps { + readonly scene: Scene; + readonly isEditable: boolean; + readonly onToggleEditMode?: () => void; +} + +/** + * Edit-mode root — wraps the Pro mode chrome assembly so `stage.tsx` + * has a single component to mount in the edit branch instead of a + * 13-line inline JSX with three children. + * + * Owned here: `EditShell` (Frame + CommandBar + canvas + overlays), + * `SlideNavRail` (leftRail slot), and the `HeaderControls` trailing + * (settings pill + Pro Switch) that rides in CommandBar's right slot. + * + * NOT owned here: + * - `MultiTabEditConflictPrompt` — must mount even in playback mode so + * the lock-conflict dialog can be shown when entering edit mode is + * refused (mode is still 'playback' at that point). + * - `useEditModeLock` — the lock is acquired by the Pro toggle in + * stage.tsx BEFORE the live session is torn down, so it can't live + * in a component that only mounts after the switch. + * + * `scene` is required (non-null). The parent gates mounting on + * `mode === 'edit' && currentScene` to satisfy this contract. + */ +export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChromeRootProps) { + // Mark the body while edit mode is mounted, so the editor-scoped CSS + // rule in globals.css that pins `body.padding-right` to 0 only fires + // in Pro mode — not on non-editor pages where Radix's + // react-remove-scroll compensation is still wanted. Lifted from + // SlideCanvas (which was mounted only for slide scenes) so the + // attribute now covers read-only scene types in Pro mode too. + useEffect(() => { + document.body.dataset.maicEditor = 'true'; + return () => { + delete document.body.dataset.maicEditor; + }; + }, []); + + return ( + } + commandTrailing={ + + } + /> + ); +} diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx index 6e518823b4..ba397bc368 100644 --- a/components/edit/EditShell/EditShell.tsx +++ b/components/edit/EditShell/EditShell.tsx @@ -1,11 +1,13 @@ 'use client'; -import { motion } from 'motion/react'; -import type { ReactNode } from 'react'; -import type { SceneEditorSurface } from '@/lib/edit/scene-editor-surface'; +import { motion, useReducedMotion } from 'motion/react'; +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import type { SceneEditorSurface, SurfaceState } from '@/lib/edit/scene-editor-surface'; import { sceneEditorRegistry } from '@/lib/edit/scene-editor-registry'; -import { useI18n } from '@/lib/hooks/use-i18n'; +import { NOOP_SURFACE } from '@/lib/edit/noop-surface'; import type { Scene } from '@/lib/types/stage'; +import { CHROME_DURATION, CHROME_EASE, CHROME_STAGGER } from '@/lib/edit/transitions'; +import { StageGrid } from '@/components/edit/StageGrid'; import { CommandBar } from './CommandBar'; import { FloatingToolbar } from './FloatingToolbar'; import { HintRail } from './HintRail'; @@ -13,15 +15,23 @@ import { HintRail } from './HintRail'; interface EditShellProps { readonly scene: Scene; /** - * Optional left-side navigator slot. v0 ships with this empty — a future - * sub-PR will plug in the redesigned slide-navigation surface here. The - * prop is preserved as an extension point so Stage doesn't need to grow a - * new layout when that lands. + * Optional left-side navigator slot. In v0 this is the SlideNavRail + * passed from Stage when mode === 'edit'. Surface code never imports + * the rail (the prop is the only handoff seam), keeping chrome and + * surface separable. */ readonly leftRail?: ReactNode; + /** + * Right-edge slot of the CommandBar — Stage uses this to hand in the + * global controls (settings pill + Pro Switch) when the Stage Header + * is hidden, so the entire top chrome reduces to a single bar. + */ + readonly commandTrailing?: ReactNode; } -const CHROME_TRANSITION = { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }; +const CHROME_TRANSITION = { duration: CHROME_DURATION, ease: CHROME_EASE } as const; +const COMMANDBAR_DELAY = CHROME_STAGGER; +const LEFT_RAIL_DELAY = CHROME_STAGGER * 2; /** * Pro mode (edit) chrome — mounts inside the canvas slot of Stage, replacing @@ -39,64 +49,114 @@ const CHROME_TRANSITION = { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const }; * │ │ HintRail (AI, reserved) │ * └──────────┴───────────────────────────────────┘ * - * When a surface is registered for `scene.type`, EditShell renders that - * surface's canvas and reads its useSurfaceState() into the CommandBar / - * FloatingToolbar / HintRail slots. When none is registered, it falls - * through to the `edit.unsupportedScene` placeholder — the visible v0 - * behavior since no surfaces ship in this PR. + * Mount choreography: CommandBar drops in from top, leftRail slides in + * from left after a stagger, content opacity-fades in. All three share + * the single `CHROME_*` source in `lib/edit/transitions.ts` so timing + * stays consistent with the outer Stage-level cross-fade. + * + * Architecture: this shell resolves `scene.type` to a registered surface + * (or falls back to NOOP_SURFACE for unregistered types) and **never + * branches into a different component type**. The same `` mounts + * across every scene-type change — only the `surface.CanvasComponent` + * inside the canvas slot swaps. That guarantees CommandBar and `leftRail` + * never remount during scene navigation, removing the chrome flicker that + * the previous two-branch design caused (PR3a rearch). */ -export function EditShell({ scene, leftRail }: EditShellProps) { - const surface = sceneEditorRegistry.resolve(scene.type); - - if (surface) { - return ; - } - return ; -} +export function EditShell({ scene, leftRail, commandTrailing }: EditShellProps) { + const surface = sceneEditorRegistry.resolve(scene.type) ?? NOOP_SURFACE; + // Surface state is published from a child runner (keyed by sceneType so it + // remounts when the surface identity changes — that's the boundary at which + // rules-of-hooks naturally allows a different hook signature). The chrome + // around it stays mounted and consumes state via these props. + const [state, setState] = useState(null); + const CanvasComponent = surface.CanvasComponent; -interface ResolvedShellProps { - readonly scene: Scene; - readonly leftRail?: ReactNode; + return ( + <> + {/* `key={scene.type}` is the remount boundary. We can't use + `surface.sceneType` here because NOOP_SURFACE deliberately reuses + 'slide' as a placeholder (the SceneType union is closed and NOOP + isn't a real type). The scene's own `type` is the actual signal + that the hook signature inside `useSurfaceState` is about to + change — so we remount the runner exactly when it does, keeping + rules-of-hooks happy across the slide ↔ read-only surface swap + while the rest of the chrome stays mounted. */} + + + + {state?.hasSelection && } + + + + ); } -function EditShellWithSurface({ - scene, +/** + * Hidden runner that owns the surface state hook. `key={surface.sceneType}` + * ensures it remounts when the surface itself changes (slide → noop) — the + * only point at which the hook call signature can vary. Within a single mount + * the hook signature is fixed (the surface object is constant), so React's + * rules-of-hooks are respected. + * + * Renders no DOM; state flows up to the chrome via `onChange`. A custom + * shallow comparison gates the publish — surface hooks (e.g. slideSurface's + * `useSlideSurfaceState`) return a fresh object literal every render, so naive + * reference equality would loop infinitely (every publish causes the parent to + * re-render, which re-runs this hook, which yields a new ref, which publishes + * again, etc.). We only publish when one of the fields the chrome actually + * reads has materially changed. + */ +function SurfaceStateRunner({ surface, - leftRail, -}: ResolvedShellProps & { readonly surface: SceneEditorSurface }) { - const { t } = useI18n(); - const sceneTypeLabel = t(`edit.sceneType.${scene.type}`); - const title = t('edit.title', { type: sceneTypeLabel }); + onChange, +}: { + readonly surface: SceneEditorSurface; + readonly onChange: (state: SurfaceState) => void; +}) { const state = surface.useSurfaceState(); - const Canvas = surface.CanvasComponent; - - return ( - - - {state.hasSelection && } - - - ); + const lastRef = useRef(null); + useLayoutEffect(() => { + if (surfaceStateEqual(state, lastRef.current)) return; + lastRef.current = state; + onChange(state); + }); + return null; } -function EditShellFallback({ scene, leftRail }: ResolvedShellProps) { - const { t } = useI18n(); - const sceneTypeLabel = t(`edit.sceneType.${scene.type}`); - const title = t('edit.title', { type: sceneTypeLabel }); - - return ( - -
- {t('edit.unsupportedScene', { type: sceneTypeLabel })} -
- - ); +/** + * Field-by-field equality for the subset of SurfaceState that the chrome + * reads (CommandBar history/insertItems/commands + Frame.hasSelection + + * floating/hints). Reference-equal `content` is the canonical signal that + * the slide buffer hasn't changed; insert/command arrays are compared by + * length + per-item `active` flag (which is what CommandBar styles on). + */ +function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean { + if (!b) return false; + if (a.content !== b.content) return false; + if (a.hasSelection !== b.hasSelection) return false; + if ((a.history?.canUndo ?? null) !== (b.history?.canUndo ?? null)) return false; + if ((a.history?.canRedo ?? null) !== (b.history?.canRedo ?? null)) return false; + if (a.insertItems.length !== b.insertItems.length) return false; + for (let i = 0; i < a.insertItems.length; i++) { + if (a.insertItems[i].id !== b.insertItems[i].id) return false; + if (a.insertItems[i].active !== b.insertItems[i].active) return false; + if (a.insertItems[i].disabled !== b.insertItems[i].disabled) return false; + } + if (a.commands.length !== b.commands.length) return false; + for (let i = 0; i < a.commands.length; i++) { + if (a.commands[i].id !== b.commands[i].id) return false; + if (a.commands[i].disabled !== b.commands[i].disabled) return false; + } + if (a.floatingActions.length !== b.floatingActions.length) return false; + if ((a.hints?.length ?? 0) !== (b.hints?.length ?? 0)) return false; + return true; } interface FrameProps { @@ -105,23 +165,75 @@ interface FrameProps { readonly history?: React.ComponentProps['history']; readonly insertItems?: React.ComponentProps['insertItems']; readonly commands?: React.ComponentProps['commands']; + readonly trailing?: ReactNode; readonly children: ReactNode; } -function Frame({ title, leftRail, history, insertItems, commands, children }: FrameProps) { +function Frame({ + title, + leftRail, + history, + insertItems, + commands, + trailing, + children, +}: FrameProps) { + const prefersReducedMotion = useReducedMotion(); + + // When reduced motion is requested, drop transforms (y/x) and only fade. + // Layout remains static and timing collapses to ~120ms. + const cmdInitial = prefersReducedMotion ? { opacity: 0 } : { y: -56, opacity: 0 }; + const cmdAnimate = prefersReducedMotion ? { opacity: 1 } : { y: 0, opacity: 1 }; + const railInitial = prefersReducedMotion ? { opacity: 0 } : { x: -32, opacity: 0 }; + const railAnimate = prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }; + + const stepTransition = prefersReducedMotion + ? { duration: 0.12, ease: CHROME_EASE } + : CHROME_TRANSITION; + return ( -
- - - -
- {leftRail} -
{children}
-
-
+ + + + } + leftSlot={ + leftRail ? ( + + {leftRail} + + ) : null + } + centerSlot={ + // Padded studio frame around the actual scene renderer. Lifted + // up from SlideCanvas so the slide and the non-slide read-only + // renderers share the exact same canvas bounding rect (no + // layout jump when switching scene type). Children render + // inside an inner ring/shadow card that the playback + // CanvasArea visually mirrors. +
+
+ {children} +
+
+ } + /> ); } diff --git a/components/edit/PlaybackChromeRoot.tsx b/components/edit/PlaybackChromeRoot.tsx new file mode 100644 index 0000000000..fa9d0cefac --- /dev/null +++ b/components/edit/PlaybackChromeRoot.tsx @@ -0,0 +1,1332 @@ +'use client'; + +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { useStageStore } from '@/lib/store'; +import { PENDING_SCENE_ID } from '@/lib/store/stage'; +import { useCanvasStore } from '@/lib/store/canvas'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { SceneSidebar } from '@/components/stage/scene-sidebar'; +import { Header } from '@/components/header'; +import { CanvasArea } from '@/components/canvas/canvas-area'; +import { Roundtable } from '@/components/roundtable'; +import { PlaybackEngine, computePlaybackView } from '@/lib/playback'; +import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback'; +import { ActionEngine } from '@/lib/action/engine'; +import { createAudioPlayer } from '@/lib/utils/audio-player'; +import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts'; +import { useWidgetIframeStore } from '@/lib/store/widget-iframe'; +import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator'; +import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action'; +import { cn } from '@/lib/utils'; +// Playback state persistence removed — refresh always starts from the beginning +import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area'; +import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store'; +import type { AgentConfig } from '@/lib/orchestration/registry/types'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogTitle, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { AlertTriangle } from 'lucide-react'; +import { VisuallyHidden } from 'radix-ui'; + +/** + * Imperative handle exposed via `ref` so the parent (`Stage`) can tear + * down playback state synchronously before flipping mode to `'edit'`. + * Unmount cleanup would run anyway, but the toggle needs to `await` + * `endActiveSession()` (which aborts SSE) before we trust the engine / + * chat to be quiescent — fire-and-forget on unmount loses that guarantee. + */ +export interface PlaybackChromeRootHandle { + /** Ends any active SSE session, stops the engine, cleans up TTS audio. */ + teardown: () => Promise; +} + +interface PlaybackChromeRootProps { + readonly onRetryOutline?: (outlineId: string) => Promise; + /** Whether the Pro Switch in Header should be enabled. */ + readonly canEnterProMode?: boolean; + /** Pro Switch click handler — parent coordinates editLock + teardown. */ + readonly onEnterProMode?: () => void; +} + +/** + * PlaybackChromeRoot — owns the entire playback/autonomous chrome and + * its state. Mounted whenever `mode !== 'edit'`. The Pro Switch in + * `Header` calls `onEnterProMode`; the parent `Stage` is responsible + * for calling `ref.teardown()` before unmounting this root so SSE and + * the engine wind down cleanly. + */ +export const PlaybackChromeRoot = forwardRef( + function PlaybackChromeRoot({ onRetryOutline, canEnterProMode, onEnterProMode }, ref) { + const { t } = useI18n(); + const { + mode, + getCurrentScene, + scenes, + currentSceneId, + setCurrentSceneId, + generatingOutlines, + outlines, + } = useStageStore(); + const failedOutlines = useStageStore.use.failedOutlines(); + + const currentScene = getCurrentScene(); + + // Layout state from settings store (persisted via localStorage) + const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed); + const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed); + const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth); + const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); + const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); + const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); + const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); + const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); + + // PlaybackEngine state + const [engineMode, setEngineMode] = useState('idle'); + const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle + const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture) + const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA) + const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1) + const [discussionTrigger, setDiscussionTrigger] = useState(null); + + // Speaking agent tracking (Issue 2) + const [speakingAgentId, setSpeakingAgentId] = useState(null); + + // Thinking state (Issue 5) + const [thinkingState, setThinkingState] = useState<{ + stage: string; + agentId?: string; + } | null>(null); + + // Cue user state (Issue 7) + const [isCueUser, setIsCueUser] = useState(false); + + // End flash state (Issue 3) + const [showEndFlash, setShowEndFlash] = useState(false); + const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion'); + + // Streaming state for stop button (Issue 1) + const [chatIsStreaming, setChatIsStreaming] = useState(false); + const [chatSessionType, setChatSessionType] = useState(null); + + // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input + const [isTopicPending, setIsTopicPending] = useState(false); + + // Active bubble ID for playback highlight in chat area (Issue 8) + const [activeBubbleId, setActiveBubbleId] = useState(null); + + // Scene switch confirmation dialog state + const [pendingSceneId, setPendingSceneId] = useState(null); + const [isPresenting, setIsPresenting] = useState(false); + const [controlsVisible, setControlsVisible] = useState(true); + const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false); + + // Whiteboard state (from canvas store so AI tools can open it) + const whiteboardOpen = useCanvasStore.use.whiteboardOpen(); + const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen(); + + // Selected agents from settings store (Zustand) + const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); + const ttsMuted = useSettingsStore((s) => s.ttsMuted); + const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); + + // Generate participants from selected agents + const participants = useMemo( + () => agentsToParticipants(selectedAgentIds, t), + [selectedAgentIds, t], + ); + + // Resolved AgentConfig array for hooks that need full agent objects + // Subscribe to the agents record so voiceConfig changes trigger re-resolution + const agentsRecord = useAgentRegistry((s) => s.agents); + const selectedAgents = useMemo( + () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null), + [agentsRecord, selectedAgentIds], + ); + + // Discussion TTS: audio indicator state + const [audioIndicatorState, setAudioIndicatorState] = useState('idle'); + const [audioAgentId, setAudioAgentId] = useState(null); + + const discussionTTS = useDiscussionTTS({ + enabled: ttsEnabled && !ttsMuted, + agents: selectedAgents, + onAudioStateChange: (agentId, state) => { + setAudioAgentId(agentId); + setAudioIndicatorState(state); + }, + }); + + // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback) + const pickStudentAgent = useCallback((): string => { + const registry = useAgentRegistry.getState(); + const agents = selectedAgentIds + .map((id) => registry.getAgent(id)) + .filter((a): a is AgentConfig => a != null); + const students = agents.filter((a) => a.role === 'student'); + if (students.length > 0) { + return students[Math.floor(Math.random() * students.length)].id; + } + const nonTeachers = agents.filter((a) => a.role !== 'teacher'); + if (nonTeachers.length > 0) { + return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id; + } + return agents[0]?.id || 'default-1'; + }, [selectedAgentIds]); + + const engineRef = useRef(null); + const audioPlayerRef = useRef(createAudioPlayer()); + const chatAreaRef = useRef(null); + const lectureSessionIdRef = useRef(null); + const lectureActionCounterRef = useRef(0); + const discussionAbortRef = useRef(null); + const presentationIdleTimerRef = useRef | null>(null); + const stageRef = useRef(null); + // Guard to prevent double flash when manual stop triggers onDiscussionEnd + const manualStopRef = useRef(false); + // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks + const sceneEpochRef = useRef(0); + // When true, the next engine init will auto-start playback (for auto-play scene advance) + const autoStartRef = useRef(false); + // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE) + const [isDiscussionPaused, setIsDiscussionPaused] = useState(false); + + /** + * Resume a soft-paused topic: re-call /chat with existing session messages. + * The director picks the next agent to continue. + */ + const doResumeTopic = useCallback(async () => { + // Clear old bubble immediately — no lingering on interrupted text + setIsTopicPending(false); + setLiveSpeech(null); + setSpeakingAgentId(null); + setThinkingState({ stage: 'director' }); + setChatIsStreaming(true); + // Transition engine back to live — onInputActivate paused it when soft-pausing, + // so we must explicitly resume to keep engine mode in sync with the chat loop. + engineRef.current?.resume(); + // Fire new chat round — SSE events will drive thinking → agent_start → speech + await chatAreaRef.current?.resumeActiveSession(); + }, []); + + /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */ + const resetLiveState = useCallback(() => { + setLiveSpeech(null); + setSpeakingAgentId(null); + setSpeechProgress(null); + setThinkingState(null); + setIsCueUser(false); + setIsTopicPending(false); + setChatIsStreaming(false); + setChatSessionType(null); + setIsDiscussionPaused(false); + }, []); + + /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */ + const resetSceneState = useCallback(() => { + resetLiveState(); + setPlaybackCompleted(false); + setLectureSpeech(null); + setSpeechProgress(null); + setShowEndFlash(false); + setActiveBubbleId(null); + setDiscussionTrigger(null); + }, [resetLiveState]); + + /** Request failure should exit live discussion UI without hard-closing the session. */ + const handleLiveSessionError = useCallback(() => { + engineRef.current?.handleDiscussionError(); + resetLiveState(); + setActiveBubbleId(null); + }, [resetLiveState]); + + /** + * Unified session cleanup — called by both roundtable stop button and chat area end button. + * Handles: engine transition, flash, roundtable state clearing. + */ + const doSessionCleanup = useCallback(() => { + const activeType = chatSessionType; + + // Engine cleanup — guard to avoid double flash from onDiscussionEnd + manualStopRef.current = true; + engineRef.current?.handleEndDiscussion(); + manualStopRef.current = false; + + // Show end flash with correct session type + if (activeType === 'qa' || activeType === 'discussion') { + setEndFlashSessionType(activeType); + setShowEndFlash(true); + setTimeout(() => setShowEndFlash(false), 1800); + } + + // Stop any in-flight discussion TTS audio + discussionTTS.cleanup(); + + resetLiveState(); + }, [chatSessionType, resetLiveState, discussionTTS]); + + // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) + const handleStopDiscussion = useCallback(async () => { + await chatAreaRef.current?.endActiveSession(); + doSessionCleanup(); + }, [doSessionCleanup]); + + // Imperative teardown so the parent can `await` SSE / engine / TTS + // shutdown before flipping mode to 'edit'. Mirrors what the old in- + // component `handleToggleEditMode` did, but exposed through ref so + // the toggle lives one layer up. + useImperativeHandle( + ref, + () => ({ + teardown: async () => { + await chatAreaRef.current?.endActiveSession(); + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + discussionAbortRef.current = null; + } + engineRef.current?.stop(); + discussionTTS.cleanup(); + resetSceneState(); + }, + }), + [discussionTTS, resetSceneState], + ); + + const clearPresentationIdleTimer = useCallback(() => { + if (presentationIdleTimerRef.current) { + clearTimeout(presentationIdleTimerRef.current); + presentationIdleTimerRef.current = null; + } + }, []); + + const resetPresentationIdleTimer = useCallback(() => { + setControlsVisible(true); + clearPresentationIdleTimer(); + if (isPresenting && !isPresentationInteractionActive) { + presentationIdleTimerRef.current = setTimeout(() => { + setControlsVisible(false); + }, 3000); + } + }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]); + + const togglePresentation = useCallback(async () => { + const stageElement = stageRef.current; + if (!stageElement) return; + + try { + if (document.fullscreenElement === stageElement) { + // Unlock Escape key before exiting fullscreen + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).keyboard?.unlock?.(); + await document.exitFullscreen(); + return; + } + + setControlsVisible(true); + await stageElement.requestFullscreen(); + // Lock Escape key so it doesn't auto-exit fullscreen (#255) + // Escape is handled manually in our keydown handler instead + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); + setSidebarCollapsed(true); + setChatAreaCollapsed(true); + } catch { + // Firefox may deny fullscreen from certain keyboard events (e.g. F11) + console.warn('[Presentation] Fullscreen request denied — browser policy'); + } + }, [setChatAreaCollapsed, setSidebarCollapsed]); + + useEffect(() => { + const onFullscreenChange = () => { + const active = document.fullscreenElement === stageRef.current; + setIsPresenting(active); + + if (!active) { + // Ensure keyboard unlock on any fullscreen exit + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).keyboard?.unlock?.(); + setControlsVisible(true); + clearPresentationIdleTimer(); + } + }; + + document.addEventListener('fullscreenchange', onFullscreenChange); + return () => document.removeEventListener('fullscreenchange', onFullscreenChange); + }, [clearPresentationIdleTimer]); + + useEffect(() => { + if (!isPresenting) { + setControlsVisible(true); + clearPresentationIdleTimer(); + return; + } + + const handleActivity = () => { + resetPresentationIdleTimer(); + }; + + window.addEventListener('mousemove', handleActivity); + window.addEventListener('mousedown', handleActivity); + window.addEventListener('touchstart', handleActivity); + if (isPresentationInteractionActive) { + setControlsVisible(true); + clearPresentationIdleTimer(); + } else { + resetPresentationIdleTimer(); + } + + return () => { + window.removeEventListener('mousemove', handleActivity); + window.removeEventListener('mousedown', handleActivity); + window.removeEventListener('touchstart', handleActivity); + clearPresentationIdleTimer(); + }; + }, [ + clearPresentationIdleTimer, + isPresenting, + isPresentationInteractionActive, + resetPresentationIdleTimer, + ]); + + // Initialize playback engine when scene changes + useEffect(() => { + // Bump epoch so any stale SSE callbacks from the previous scene are discarded + sceneEpochRef.current++; + + // End any active QA/discussion session — this synchronously aborts the SSE + // stream inside use-chat-sessions (abortControllerRef.abort()), preventing + // stale onLiveSpeech callbacks from leaking into the new scene. + chatAreaRef.current?.endActiveSession(); + + // Also abort the engine-level discussion controller + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + discussionAbortRef.current = null; + } + + // Stop any in-flight discussion TTS audio on scene switch + discussionTTS.cleanup(); + + // Reset all roundtable/live state so scenes are fully isolated + resetSceneState(); + + if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) { + engineRef.current = null; + setEngineMode('idle'); + + return; + } + + // Stop previous engine + if (engineRef.current) { + engineRef.current.stop(); + } + + // Get widget iframe messaging callback for interactive scenes (keyed by sceneId) + const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id); + + // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging) + const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current, widgetSendMessage); + + // Create new PlaybackEngine + const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, { + onModeChange: (mode) => { + setEngineMode(mode); + }, + onSceneChange: (_sceneId) => { + // Scene change handled by engine + }, + onSpeechStart: (text) => { + setLectureSpeech(text); + // Add to lecture session with incrementing index for dedup + // Chat area pacing is handled by the StreamBuffer (onTextReveal) + if (lectureSessionIdRef.current) { + const idx = lectureActionCounterRef.current++; + const speechId = `speech-${Date.now()}`; + chatAreaRef.current?.addLectureMessage( + lectureSessionIdRef.current, + { id: speechId, type: 'speech', text } as Action, + idx, + ); + // Track active bubble for highlight (Issue 8) + const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!); + if (msgId) setActiveBubbleId(msgId); + } + }, + onSpeechEnd: () => { + // Don't clear lectureSpeech — let it persist until the next + // onSpeechStart replaces it or the scene transitions. + // Clearing here causes fallback to idleText (first sentence). + setActiveBubbleId(null); + }, + onEffectFire: (effect: Effect) => { + // Add to lecture session with incrementing index + if ( + lectureSessionIdRef.current && + (effect.kind === 'spotlight' || effect.kind === 'laser') + ) { + const idx = lectureActionCounterRef.current++; + chatAreaRef.current?.addLectureMessage( + lectureSessionIdRef.current, + { + id: `${effect.kind}-${Date.now()}`, + type: effect.kind, + elementId: effect.targetId, + } as Action, + idx, + ); + } + }, + onProactiveShow: (trigger) => { + if (!trigger.agentId) { + // Mutate in-place so engine.currentTrigger also gets the agentId + // (confirmDiscussion reads agentId from the same object reference) + trigger.agentId = pickStudentAgent(); + } + setDiscussionTrigger(trigger); + }, + onProactiveHide: () => { + setDiscussionTrigger(null); + }, + onDiscussionConfirmed: (topic, prompt, agentId) => { + // Start SSE discussion via ChatArea + handleDiscussionSSE(topic, prompt, agentId); + }, + onDiscussionEnd: () => { + // Abort any active SSE + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + discussionAbortRef.current = null; + } + setDiscussionTrigger(null); + // Stop any in-flight discussion TTS audio + discussionTTS.cleanup(); + // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup) + resetLiveState(); + // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup) + if (!manualStopRef.current) { + setEndFlashSessionType('discussion'); + setShowEndFlash(true); + setTimeout(() => setShowEndFlash(false), 1800); + } + // If all actions are exhausted (discussion was the last action), mark + // playback as completed so the bubble shows reset instead of play. + if (engineRef.current?.isExhausted()) { + setPlaybackCompleted(true); + } + }, + onUserInterrupt: (text) => { + // User interrupted → start a discussion via chat + chatAreaRef.current?.sendMessage(text); + }, + isAgentSelected: (agentId) => { + const ids = useSettingsStore.getState().selectedAgentIds; + return ids.includes(agentId); + }, + getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, + onComplete: () => { + // lectureSpeech intentionally NOT cleared — last sentence stays visible + // until scene transition (auto-play) or user restarts. Scene change + // effect handles the reset. + setPlaybackCompleted(true); + + // End lecture session on playback complete + if (lectureSessionIdRef.current) { + chatAreaRef.current?.endSession(lectureSessionIdRef.current); + lectureSessionIdRef.current = null; + } + // Auto-play: advance to next scene after a short pause + const { autoPlayLecture } = useSettingsStore.getState(); + if (autoPlayLecture) { + setTimeout(() => { + const stageState = useStageStore.getState(); + if (!useSettingsStore.getState().autoPlayLecture) return; + const allScenes = stageState.scenes; + const curId = stageState.currentSceneId; + const idx = allScenes.findIndex((s) => s.id === curId); + if (idx >= 0 && idx < allScenes.length - 1) { + const currentScene = allScenes[idx]; + if ( + currentScene.type === 'quiz' || + currentScene.type === 'interactive' || + currentScene.type === 'pbl' + ) { + return; + } + autoStartRef.current = true; + stageState.setCurrentSceneId(allScenes[idx + 1].id); + } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) { + // Last scene exhausted but next is still generating — go to pending page + const currentScene = allScenes[idx]; + if ( + currentScene.type === 'quiz' || + currentScene.type === 'interactive' || + currentScene.type === 'pbl' + ) { + return; + } + autoStartRef.current = true; + stageState.setCurrentSceneId(PENDING_SCENE_ID); + } + }, 1500); + } + }, + }); + + engineRef.current = engine; + + // Auto-start if triggered by auto-play scene advance + if (autoStartRef.current) { + autoStartRef.current = false; + (async () => { + if (currentScene && chatAreaRef.current) { + const sessionId = await chatAreaRef.current.startLecture(currentScene.id); + lectureSessionIdRef.current = sessionId; + lectureActionCounterRef.current = 0; + } + engine.start(); + })(); + } else { + // Load saved playback state and restore position (but never auto-play). + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs + }, [currentScene]); + + // Cleanup on unmount + useEffect(() => { + const audioPlayer = audioPlayerRef.current; + const chatArea = chatAreaRef.current; + return () => { + if (engineRef.current) { + engineRef.current.stop(); + } + audioPlayer.destroy(); + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + } + discussionTTS.cleanup(); + chatArea?.endActiveSession(); + clearPresentationIdleTimer(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable + }, []); + + // Sync mute state from settings store to audioPlayer + useEffect(() => { + audioPlayerRef.current.setMuted(ttsMuted); + }, [ttsMuted]); + + // Sync volume from settings store to audioPlayer + const ttsVolume = useSettingsStore((s) => s.ttsVolume); + useEffect(() => { + if (!ttsMuted) { + audioPlayerRef.current.setVolume(ttsVolume); + } + }, [ttsVolume, ttsMuted]); + + // Sync playback speed to audio player (for live-updating current audio) + const playbackSpeed = useSettingsStore((s) => s.playbackSpeed); + useEffect(() => { + audioPlayerRef.current.setPlaybackRate(playbackSpeed); + }, [playbackSpeed]); + + /** + * Handle discussion SSE — POST /api/chat and push events to engine + */ + const handleDiscussionSSE = useCallback( + async (topic: string, prompt?: string, agentId?: string) => { + // Start discussion display in ChatArea (lecture speech is preserved independently) + chatAreaRef.current?.startDiscussion({ + topic, + prompt, + agentId: agentId || 'default-1', + }); + // Auto-switch to chat tab when discussion starts + chatAreaRef.current?.switchToTab('chat'); + // Immediately mark streaming for synchronized stop button + setChatIsStreaming(true); + setChatSessionType('discussion'); + // Optimistic thinking: show thinking dots immediately (same as onMessageSend) + setThinkingState({ stage: 'director' }); + }, + [], + ); + + // First speech text for idle display (extracted here for playbackView) + const firstSpeechText = useMemo( + () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null, + [currentScene], + ); + + // Whether the speaking agent is a student (for bubble role derivation) + const speakingStudentFlag = useMemo(() => { + if (!speakingAgentId) return false; + const agent = useAgentRegistry.getState().getAgent(speakingAgentId); + return agent?.role !== 'teacher'; + }, [speakingAgentId]); + + // Centralised derived playback view + const playbackView = useMemo( + () => + computePlaybackView({ + engineMode, + lectureSpeech, + liveSpeech, + speakingAgentId, + thinkingState, + isCueUser, + isTopicPending, + chatIsStreaming, + discussionTrigger, + playbackCompleted, + idleText: firstSpeechText, + speakingStudent: speakingStudentFlag, + sessionType: chatSessionType, + }), + [ + engineMode, + lectureSpeech, + liveSpeech, + speakingAgentId, + thinkingState, + isCueUser, + isTopicPending, + chatIsStreaming, + discussionTrigger, + playbackCompleted, + firstSpeechText, + speakingStudentFlag, + chatSessionType, + ], + ); + + const isTopicActive = playbackView.isTopicActive; + + /** + * Gated scene switch — if a topic is active, show AlertDialog before switching. + * Returns true if the switch was immediate, false if gated (dialog shown). + */ + const gatedSceneSwitch = useCallback( + (targetSceneId: string): boolean => { + if (targetSceneId === currentSceneId) return false; + if (isTopicActive) { + setPendingSceneId(targetSceneId); + return false; + } + setCurrentSceneId(targetSceneId); + return true; + }, + [currentSceneId, isTopicActive, setCurrentSceneId], + ); + + /** User confirmed scene switch via AlertDialog */ + const confirmSceneSwitch = useCallback(() => { + if (!pendingSceneId) return; + chatAreaRef.current?.endActiveSession(); + doSessionCleanup(); + setCurrentSceneId(pendingSceneId); + setPendingSceneId(null); + }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]); + + /** User cancelled scene switch via AlertDialog */ + const cancelSceneSwitch = useCallback(() => { + setPendingSceneId(null); + }, []); + + // play/pause toggle + const handlePlayPause = useCallback(async () => { + const engine = engineRef.current; + if (!engine) return; + + const mode = engine.getMode(); + if (mode === 'playing' || mode === 'live') { + engine.pause(); + // Pause lecture buffer so text stops immediately + if (lectureSessionIdRef.current) { + chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current); + } + } else if (mode === 'paused') { + engine.resume(); + // Resume lecture buffer + if (lectureSessionIdRef.current) { + chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current); + } + } else { + const wasCompleted = playbackCompleted; + setPlaybackCompleted(false); + // Starting playback - create/reuse lecture session + if (currentScene && chatAreaRef.current) { + const sessionId = await chatAreaRef.current.startLecture(currentScene.id); + lectureSessionIdRef.current = sessionId; + } + if (wasCompleted) { + // Restart from beginning (user clicked restart after completion) + lectureActionCounterRef.current = 0; + engine.start(); + } else { + // Continue from current position (e.g. after discussion end) + engine.continuePlayback(); + } + } + }, [playbackCompleted, currentScene]); + + // get scene information + const isPendingScene = currentSceneId === PENDING_SCENE_ID; + const hasNextPending = generatingOutlines.length > 0; + // True when every outline has materialized into a scene and nothing is + // currently generating — signals the classroom has finished and the user + // can see a completion page. Comparing scenes.length === outlines.length + // (rather than just `scenes.length > 0`) means a partial generation with + // some failed outlines does not falsely trigger completion. + const isCourseComplete = + outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0; + const canAdvanceToPendingSlot = hasNextPending || isCourseComplete; + + // previous scene (gated) + const handlePreviousScene = useCallback(() => { + if (isPendingScene) { + // From pending page → go to last real scene + if (scenes.length > 0) { + gatedSceneSwitch(scenes[scenes.length - 1].id); + } + return; + } + const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); + if (currentIndex > 0) { + gatedSceneSwitch(scenes[currentIndex - 1].id); + } + }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]); + + // next scene (gated) + const handleNextScene = useCallback(() => { + if (isPendingScene) return; // Already on pending, nowhere to go + const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); + if (currentIndex < scenes.length - 1) { + gatedSceneSwitch(scenes[currentIndex + 1].id); + } else if (canAdvanceToPendingSlot) { + // On last real scene → advance to pending slot (generating or completion page) + setCurrentSceneId(PENDING_SCENE_ID); + } + }, [ + currentSceneId, + gatedSceneSwitch, + canAdvanceToPendingSlot, + isPendingScene, + scenes, + setCurrentSceneId, + ]); + + const currentSceneIndex = isPendingScene + ? scenes.length + : scenes.findIndex((s) => s.id === currentSceneId); + const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0); + + // get action information + const totalActions = currentScene?.actions?.length || 0; + + // whiteboard toggle + const handleWhiteboardToggle = () => { + setWhiteboardOpen(!whiteboardOpen); + }; + + const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + + if (target.isContentEditable || target.closest('[contenteditable="true"]')) { + return true; + } + + return ( + target.closest( + ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '), + ) !== null + ); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser + if (event.ctrlKey || event.metaKey || event.altKey) return; + if ( + isPresentationShortcutTarget(event.target) || + isPresentationShortcutTarget(document.activeElement) + ) { + return; + } + + switch (event.key) { + case 'ArrowLeft': + if (!isPresenting) return; + event.preventDefault(); + handlePreviousScene(); + resetPresentationIdleTimer(); + break; + case 'ArrowRight': + if (!isPresenting) return; + event.preventDefault(); + handleNextScene(); + resetPresentationIdleTimer(); + break; + case ' ': + case 'Spacebar': + // During active QA/discussion, Roundtable owns Space for + // buffer-level pause/resume — don't also fire engine play/pause. + if (chatSessionType === 'qa' || chatSessionType === 'discussion') break; + event.preventDefault(); + handlePlayPause(); + break; + case 'Escape': + // With keyboard.lock(), Escape no longer auto-exits fullscreen. + // If panels are open, roundtable handles Escape (close panels). + // If no panels are open, manually exit fullscreen. + if (isPresenting && !isPresentationInteractionActive) { + event.preventDefault(); + togglePresentation(); + } + break; + case 'ArrowUp': + event.preventDefault(); + setTTSVolume(ttsVolume + 0.1); + break; + case 'ArrowDown': + event.preventDefault(); + setTTSVolume(ttsVolume - 0.1); + break; + case 'm': + case 'M': + event.preventDefault(); + setTTSMuted(!ttsMuted); + break; + case 's': + case 'S': + event.preventDefault(); + setSidebarCollapsed(!sidebarCollapsed); + break; + case 'c': + case 'C': + event.preventDefault(); + setChatAreaCollapsed(!chatAreaCollapsed); + break; + default: + break; + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [ + chatSessionType, + chatAreaCollapsed, + handleNextScene, + handlePlayPause, + handlePreviousScene, + isPresenting, + isPresentationInteractionActive, + isPresentationShortcutTarget, + resetPresentationIdleTimer, + setChatAreaCollapsed, + setSidebarCollapsed, + setTTSMuted, + setTTSVolume, + sidebarCollapsed, + togglePresentation, + ttsMuted, + ttsVolume, + ]); + + // Intercept F11 to use our presentation fullscreen instead of browser fullscreen + // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit) + useEffect(() => { + const onF11 = (event: KeyboardEvent) => { + if (event.key === 'F11') { + event.preventDefault(); + togglePresentation(); + } + }; + + window.addEventListener('keydown', onF11); + return () => window.removeEventListener('keydown', onF11); + }, [togglePresentation]); + + // Map engine mode to the CanvasArea's expected engine state + const canvasEngineState = (() => { + switch (engineMode) { + case 'playing': + case 'live': + return 'playing'; + case 'paused': + return 'paused'; + default: + return 'idle'; + } + })(); + + // Build discussion request for Roundtable ProactiveCard from trigger + const discussionRequest: DiscussionAction | null = discussionTrigger + ? { + type: 'discussion', + id: discussionTrigger.id, + topic: discussionTrigger.question, + prompt: discussionTrigger.prompt, + agentId: discussionTrigger.agentId || 'default-1', + } + : null; + + // Scene viewer height — header is 80px when visible, roundtable is + // 192px in playback mode (autonomous hides it). Mode is guaranteed + // non-'edit' here since the parent Stage unmounts this component + // when entering Pro mode. + const sceneViewerHeight = (() => { + const headerHeight = isPresenting ? 0 : 80; + const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0; + return `calc(100% - ${headerHeight + roundtableHeight}px)`; + })(); + + return ( +
+ + + {/* Main Content Area */} +
+ {/* Header — playback only. The Pro Switch fires `onEnterProMode` + (passed by the parent Stage) which acquires the cross-tab + edit lock and then awaits our `teardown()` before flipping + mode to 'edit'. */} + {!isPresenting && ( +
+ )} + + {/* Canvas Area — playback-only renderer. The parent Stage swaps + this whole PlaybackChromeRoot out when entering edit mode, so + no inline branching is needed here. */} +
+ setSidebarCollapsed(!sidebarCollapsed)} + onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} + onPrevSlide={handlePreviousScene} + onNextSlide={handleNextScene} + onPlayPause={handlePlayPause} + onWhiteboardClose={handleWhiteboardToggle} + isPresenting={isPresenting} + onTogglePresentation={togglePresentation} + showStopDiscussion={ + engineMode === 'live' || + (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) + } + onStopDiscussion={handleStopDiscussion} + hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)} + isPendingScene={isPendingScene} + isCourseComplete={isCourseComplete} + isGenerationFailed={ + isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id) + } + onRetryGeneration={ + onRetryOutline && generatingOutlines[0] + ? () => onRetryOutline(generatingOutlines[0].id) + : undefined + } + /> +
+ + {/* Roundtable Area */} + {mode === 'playback' && ( +
+ { + // Always clear Level-1 pause state — the closure may hold a stale + // isDiscussionPaused value (e.g. voice input's onTranscription callback + // captures onMessageSend before React re-renders with the updated state). + setIsDiscussionPaused(false); + // Clear the sticky livePausedRef so the next agent-loop buffer + // starts unpaused. (pauseActiveLiveBuffer sets a ref that new + // buffers inherit — must be cleared before sendMessage creates one.) + chatAreaRef.current?.resumeActiveLiveBuffer(); + // Flush any buffered / in-flight TTS audio from the previous + // agent turn so it doesn't leak into the next round. + discussionTTS.cleanup(); + // Clear soft-paused state — user is continuing the topic + if (isTopicPending) { + setIsTopicPending(false); + setLiveSpeech(null); + setSpeakingAgentId(null); + } + // User interrupts during playback — handleUserInterrupt triggers + // onUserInterrupt callback which already calls sendMessage, so skip + // the direct sendMessage below to avoid sending twice. + // Include 'paused' because onInputActivate pauses the engine before + // the user finishes typing — without this the interrupt position + // would never be saved and resuming after QA skips to the next sentence. + if ( + engineRef.current && + (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused') + ) { + engineRef.current.handleUserInterrupt(msg); + } else { + chatAreaRef.current?.sendMessage(msg); + } + // Auto-switch to chat tab when user sends a message + chatAreaRef.current?.switchToTab('chat'); + setIsCueUser(false); + // Immediately mark streaming for synchronized stop button + setChatIsStreaming(true); + setChatSessionType(chatSessionType || 'qa'); + // Optimistic thinking: show thinking dots immediately so there's + // no blank gap between userMessage expiry and the SSE thinking event. + // The real SSE event will overwrite this with the same or updated value. + setThinkingState({ stage: 'director' }); + }} + onDiscussionStart={() => { + // User clicks "Join" on ProactiveCard + engineRef.current?.confirmDiscussion(); + }} + onDiscussionSkip={() => { + // User clicks "Skip" on ProactiveCard + engineRef.current?.skipDiscussion(); + }} + onStopDiscussion={handleStopDiscussion} + onInputActivate={() => { + // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering. + // User resumes manually via Space / pause button after closing the input. + // No isDiscussionPaused guard — always attempt to pause the buffer. + // The return value ensures UI state stays in sync with buffer state. + if (chatSessionType === 'qa' || chatSessionType === 'discussion') { + const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); + if (paused) { + discussionTTS.pause(); + setIsDiscussionPaused(true); + } + } + // Also pause playback engine + if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { + engineRef.current.pause(); + } + }} + onResumeTopic={doResumeTopic} + onPlayPause={handlePlayPause} + isDiscussionPaused={isDiscussionPaused} + onDiscussionPause={() => { + const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); + if (paused) { + discussionTTS.pause(); + setIsDiscussionPaused(true); + } + }} + onDiscussionResume={() => { + chatAreaRef.current?.resumeActiveLiveBuffer(); + discussionTTS.resume(); + setIsDiscussionPaused(false); + }} + totalActions={totalActions} + currentActionIndex={0} + currentSceneIndex={currentSceneIndex} + scenesCount={totalScenesCount} + whiteboardOpen={whiteboardOpen} + sidebarCollapsed={sidebarCollapsed} + chatCollapsed={chatAreaCollapsed} + onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} + onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} + onPrevSlide={handlePreviousScene} + onNextSlide={handleNextScene} + onWhiteboardClose={handleWhiteboardToggle} + isPresenting={isPresenting} + controlsVisible={controlsVisible} + onTogglePresentation={togglePresentation} + onPresentationInteractionChange={setIsPresentationInteractionActive} + fullscreenContainerRef={stageRef} + /> +
+ )} +
+ + {/* Chat Area — playback / autonomous always renders it here; Pro + (edit) mode unmounts this whole PlaybackChromeRoot, so the + edit branch has no chat. */} +
+ setActiveBubbleId(id)} + currentSceneId={currentSceneId} + onLiveSpeech={(text, agentId) => { + // Capture epoch at call time — discard if scene has changed since + const epoch = sceneEpochRef.current; + // Use queueMicrotask to let any pending scene-switch reset settle first + queueMicrotask(() => { + if (sceneEpochRef.current !== epoch) return; // stale — scene changed + setLiveSpeech(text); + if (agentId !== undefined) { + setSpeakingAgentId(agentId); + } + if (text !== null || agentId) { + setChatIsStreaming(true); + setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null); + setIsTopicPending(false); + } else if (text === null && agentId === null) { + setChatIsStreaming(false); + // Don't clear chatSessionType here — it's needed by the stop + // button when director cues user (cue_user → done → liveSpeech null). + // It gets properly cleared in doSessionCleanup and scene change. + } + }); + }} + onSpeechProgress={(ratio) => { + const epoch = sceneEpochRef.current; + queueMicrotask(() => { + if (sceneEpochRef.current !== epoch) return; + setSpeechProgress(ratio); + }); + }} + onThinking={(state) => { + const epoch = sceneEpochRef.current; + queueMicrotask(() => { + if (sceneEpochRef.current !== epoch) return; + setThinkingState(state); + }); + }} + onCueUser={(_fromAgentId, _prompt) => { + setIsCueUser(true); + }} + onLiveSessionError={handleLiveSessionError} + onStopSession={doSessionCleanup} + onSegmentSealed={discussionTTS.handleSegmentSealed} + shouldHoldAfterReveal={discussionTTS.shouldHold} + /> +
+ + {/* Scene switch confirmation dialog */} + { + if (!open) cancelSceneSwitch(); + }} + > + + + {t('stage.confirmSwitchTitle')} + + {/* Top accent bar */} +
+ +
+ {/* Icon */} +
+ +
+ {/* Title */} +

+ {t('stage.confirmSwitchTitle')} +

+ {/* Description */} +

+ {t('stage.confirmSwitchMessage')} +

+
+ + + + {t('common.cancel')} + + + {t('common.confirm')} + + + + +
+ ); + }, +); diff --git a/components/edit/StageGrid.tsx b/components/edit/StageGrid.tsx new file mode 100644 index 0000000000..3d506cd81b --- /dev/null +++ b/components/edit/StageGrid.tsx @@ -0,0 +1,66 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +interface StageGridProps { + /** Top bar — auto-height row spanning the full width (e.g. CommandBar). */ + readonly topSlot?: ReactNode; + /** Left column — auto-width (e.g. SlideNavRail). */ + readonly leftSlot?: ReactNode; + /** Right column — auto-width (future: properties panel, AI hints). */ + readonly rightSlot?: ReactNode; + /** Bottom bar — auto-height row spanning the full width (future: timeline). */ + readonly bottomSlot?: ReactNode; + /** Center cell — fills remaining space, hosts the scene canvas. */ + readonly centerSlot: ReactNode; + readonly className?: string; +} + +/** + * Edit-mode chrome layout shell with five named slots + * (top / left / right / bottom / center). Optional slots collapse to + * zero width / height when not provided, so adding a right or bottom + * panel later is a drop-in prop — no restructure of the existing edit + * tree. The center slot is mandatory and gets `minWidth: 0` / + * `minHeight: 0` so its children can shrink correctly (the usual + * "flex / grid item won't shrink below content" trap). + * + * ┌──────────────────────────────────────────────┐ + * │ topSlot │ + * ├──────────┬─────────────────────┬─────────────┤ + * │ leftSlot │ centerSlot │ rightSlot │ + * ├──────────┴─────────────────────┴─────────────┤ + * │ bottomSlot │ + * └──────────────────────────────────────────────┘ + * + * Inline `gridTemplateAreas` is used instead of Tailwind utility + * classes because Tailwind can't statically generate dynamic named + * areas; the grid template is a literal shape, not a state-dependent + * one, so the inline style is stable. + */ +const GRID_STYLE: CSSProperties = { + display: 'grid', + gridTemplateAreas: `"top top top" "left center right" "bottom bottom bottom"`, + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + gridTemplateRows: 'auto minmax(0, 1fr) auto', +}; + +export function StageGrid({ + topSlot, + leftSlot, + rightSlot, + bottomSlot, + centerSlot, + className, +}: StageGridProps) { + return ( +
+ {topSlot ?
{topSlot}
: null} + {leftSlot ?
{leftSlot}
: null} +
+ {centerSlot} +
+ {rightSlot ?
{rightSlot}
: null} + {bottomSlot ?
{bottomSlot}
: null} +
+ ); +} diff --git a/components/edit/surfaces/slide/SlideCanvas.tsx b/components/edit/surfaces/slide/SlideCanvas.tsx index aacdcb1d60..6768f0a392 100644 --- a/components/edit/surfaces/slide/SlideCanvas.tsx +++ b/components/edit/surfaces/slide/SlideCanvas.tsx @@ -44,22 +44,16 @@ export function SlideCanvas() { return () => document.removeEventListener('keydown', handler); }, []); - // Mark the body while the editor is mounted, so the editor-scoped CSS rule - // in globals.css that pins `body.padding-right` to 0 only fires here — not - // on non-editor pages where Radix's react-remove-scroll compensation is - // still wanted. - useEffect(() => { - document.body.dataset.maicEditor = 'true'; - return () => { - delete document.body.dataset.maicEditor; - }; - }, []); - return ( // gestureProps marks pointer-gesture windows so a renderer commit is // classified as a real user edit vs ResizeObserver text normalization - // (which fires with no gesture in flight). -
+ // (which fires with no gesture in flight). The padded studio frame + // around the canvas now lives in EditShell.Frame so non-slide scenes + // (rendered via SceneRenderer in read-only mode) share the exact + // same canvas bounding rect — switching scene type no longer + // resizes / reflows the frame, which used to cause the slide↔ + // interactive layout jump. +
diff --git a/components/stage.tsx b/components/stage.tsx index 97b87dd051..c8ae1b2d36 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -1,280 +1,47 @@ 'use client'; -import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useStageStore } from '@/lib/store'; -import { PENDING_SCENE_ID } from '@/lib/store/stage'; import { isCurrentSceneEditable } from '@/lib/edit/stage-mode'; -import { useCanvasStore } from '@/lib/store/canvas'; -import { useSettingsStore } from '@/lib/store/settings'; -import { useI18n } from '@/lib/hooks/use-i18n'; -import { SceneSidebar } from './stage/scene-sidebar'; -import { Header } from './header'; import { isMaicEditorEnabled } from '@/lib/config/feature-flags'; -import { CanvasArea } from '@/components/canvas/canvas-area'; -import { EditShell } from '@/components/edit/EditShell'; +import { EditChromeRoot } from '@/components/edit/EditChromeRoot'; +import { + PlaybackChromeRoot, + type PlaybackChromeRootHandle, +} from '@/components/edit/PlaybackChromeRoot'; import { useEditModeLock } from '@/components/edit/use-edit-mode-lock'; import { MultiTabEditConflictPrompt } from '@/components/edit/MultiTabEditConflictPrompt'; // Side-effect: registers the slide SceneEditorSurface so EditShell can // resolve it the moment Pro mode is entered (the shell never imports // surfaces directly). import '@/components/edit/surfaces/slide'; -import { Roundtable } from '@/components/roundtable'; -import { PlaybackEngine, computePlaybackView } from '@/lib/playback'; -import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback'; -import { ActionEngine } from '@/lib/action/engine'; -import { createAudioPlayer } from '@/lib/utils/audio-player'; -import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts'; -import { useWidgetIframeStore } from '@/lib/store/widget-iframe'; -import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator'; -import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action'; -import { cn } from '@/lib/utils'; -// Playback state persistence removed — refresh always starts from the beginning -import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area'; -import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store'; -import type { AgentConfig } from '@/lib/orchestration/registry/types'; -import { - AlertDialog, - AlertDialogContent, - AlertDialogTitle, - AlertDialogFooter, - AlertDialogAction, - AlertDialogCancel, -} from '@/components/ui/alert-dialog'; -import { AlertTriangle } from 'lucide-react'; -import { VisuallyHidden } from 'radix-ui'; /** - * Stage Component + * Stage — top-level classroom container. Dispatches between the two + * chrome roots based on `useStageStore.mode`: + * + * mode === 'edit' → EditChromeRoot + * mode === 'playback' / 'autonomous' → PlaybackChromeRoot * - * The main container for the classroom/course. - * Combines sidebar (scene navigation) and content area (scene viewer). - * Supports two modes: autonomous and playback. + * The two roots are wholly independent. Stage's only responsibilities + * are: mode dispatch, edit-lock coordination (cross-tab), Pro Switch + * toggle wiring (calls into PlaybackChromeRoot.teardown via ref before + * flipping mode), and rendering the cross-tab conflict prompt (which + * needs to be mountable from playback mode too, since the lock-conflict + * dialog can surface when Pro Switch is clicked but acquire fails). */ export function Stage({ onRetryOutline, }: { onRetryOutline?: (outlineId: string) => Promise; }) { - const { t } = useI18n(); - const { - mode, - setMode, - getCurrentScene, - scenes, - currentSceneId, - setCurrentSceneId, - generatingOutlines, - outlines, - stage, - } = useStageStore(); - const failedOutlines = useStageStore.use.failedOutlines(); - - const currentScene = getCurrentScene(); - - // Layout state from settings store (persisted via localStorage) - const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed); - const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed); - const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth); - const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); - const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); - const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); - const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); - const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); - - // PlaybackEngine state - const [engineMode, setEngineMode] = useState('idle'); - const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle - const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture) - const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA) - const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1) - const [discussionTrigger, setDiscussionTrigger] = useState(null); - - // Speaking agent tracking (Issue 2) - const [speakingAgentId, setSpeakingAgentId] = useState(null); - - // Thinking state (Issue 5) - const [thinkingState, setThinkingState] = useState<{ - stage: string; - agentId?: string; - } | null>(null); - - // Cue user state (Issue 7) - const [isCueUser, setIsCueUser] = useState(false); - - // End flash state (Issue 3) - const [showEndFlash, setShowEndFlash] = useState(false); - const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion'); - - // Streaming state for stop button (Issue 1) - const [chatIsStreaming, setChatIsStreaming] = useState(false); - const [chatSessionType, setChatSessionType] = useState(null); - - // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input - const [isTopicPending, setIsTopicPending] = useState(false); - - // Active bubble ID for playback highlight in chat area (Issue 8) - const [activeBubbleId, setActiveBubbleId] = useState(null); - - // Scene switch confirmation dialog state - const [pendingSceneId, setPendingSceneId] = useState(null); - const [isPresenting, setIsPresenting] = useState(false); - const [controlsVisible, setControlsVisible] = useState(true); - const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false); - - // Whiteboard state (from canvas store so AI tools can open it) - const whiteboardOpen = useCanvasStore.use.whiteboardOpen(); - const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen(); - - // Selected agents from settings store (Zustand) - const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); - const ttsMuted = useSettingsStore((s) => s.ttsMuted); - const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); - - // Generate participants from selected agents - const participants = useMemo( - () => agentsToParticipants(selectedAgentIds, t), - [selectedAgentIds, t], - ); - - // Resolved AgentConfig array for hooks that need full agent objects - // Subscribe to the agents record so voiceConfig changes trigger re-resolution - const agentsRecord = useAgentRegistry((s) => s.agents); - const selectedAgents = useMemo( - () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null), - [agentsRecord, selectedAgentIds], - ); - - // Discussion TTS: audio indicator state - const [audioIndicatorState, setAudioIndicatorState] = useState('idle'); - const [audioAgentId, setAudioAgentId] = useState(null); - - const discussionTTS = useDiscussionTTS({ - enabled: ttsEnabled && !ttsMuted, - agents: selectedAgents, - onAudioStateChange: (agentId, state) => { - setAudioAgentId(agentId); - setAudioIndicatorState(state); - }, - }); - - // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback) - const pickStudentAgent = useCallback((): string => { - const registry = useAgentRegistry.getState(); - const agents = selectedAgentIds - .map((id) => registry.getAgent(id)) - .filter((a): a is AgentConfig => a != null); - const students = agents.filter((a) => a.role === 'student'); - if (students.length > 0) { - return students[Math.floor(Math.random() * students.length)].id; - } - const nonTeachers = agents.filter((a) => a.role !== 'teacher'); - if (nonTeachers.length > 0) { - return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id; - } - return agents[0]?.id || 'default-1'; - }, [selectedAgentIds]); - - const engineRef = useRef(null); - const audioPlayerRef = useRef(createAudioPlayer()); - const chatAreaRef = useRef(null); - const lectureSessionIdRef = useRef(null); - const lectureActionCounterRef = useRef(0); - const discussionAbortRef = useRef(null); - const presentationIdleTimerRef = useRef | null>(null); - const stageRef = useRef(null); - // Guard to prevent double flash when manual stop triggers onDiscussionEnd - const manualStopRef = useRef(false); - // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks - const sceneEpochRef = useRef(0); - // When true, the next engine init will auto-start playback (for auto-play scene advance) - const autoStartRef = useRef(false); - // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE) - const [isDiscussionPaused, setIsDiscussionPaused] = useState(false); - - /** - * Resume a soft-paused topic: re-call /chat with existing session messages. - * The director picks the next agent to continue. - */ - const doResumeTopic = useCallback(async () => { - // Clear old bubble immediately — no lingering on interrupted text - setIsTopicPending(false); - setLiveSpeech(null); - setSpeakingAgentId(null); - setThinkingState({ stage: 'director' }); - setChatIsStreaming(true); - // Transition engine back to live — onInputActivate paused it when soft-pausing, - // so we must explicitly resume to keep engine mode in sync with the chat loop. - engineRef.current?.resume(); - // Fire new chat round — SSE events will drive thinking → agent_start → speech - await chatAreaRef.current?.resumeActiveSession(); - }, []); - - /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */ - const resetLiveState = useCallback(() => { - setLiveSpeech(null); - setSpeakingAgentId(null); - setSpeechProgress(null); - setThinkingState(null); - setIsCueUser(false); - setIsTopicPending(false); - setChatIsStreaming(false); - setChatSessionType(null); - setIsDiscussionPaused(false); - }, []); - - /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */ - const resetSceneState = useCallback(() => { - resetLiveState(); - setPlaybackCompleted(false); - setLectureSpeech(null); - setSpeechProgress(null); - setShowEndFlash(false); - setActiveBubbleId(null); - setDiscussionTrigger(null); - }, [resetLiveState]); - - /** Request failure should exit live discussion UI without hard-closing the session. */ - const handleLiveSessionError = useCallback(() => { - engineRef.current?.handleDiscussionError(); - resetLiveState(); - setActiveBubbleId(null); - }, [resetLiveState]); - - /** - * Unified session cleanup — called by both roundtable stop button and chat area end button. - * Handles: engine transition, flash, roundtable state clearing. - */ - const doSessionCleanup = useCallback(() => { - const activeType = chatSessionType; - - // Engine cleanup — guard to avoid double flash from onDiscussionEnd - manualStopRef.current = true; - engineRef.current?.handleEndDiscussion(); - manualStopRef.current = false; - - // Show end flash with correct session type - if (activeType === 'qa' || activeType === 'discussion') { - setEndFlashSessionType(activeType); - setShowEndFlash(true); - setTimeout(() => setShowEndFlash(false), 1800); - } - - // Stop any in-flight discussion TTS audio - discussionTTS.cleanup(); - - resetLiveState(); - }, [chatSessionType, resetLiveState, discussionTTS]); + const { mode, setMode, scenes, currentSceneId, generatingOutlines, stage } = useStageStore(); + const currentScene = useStageStore((s) => s.getCurrentScene()); - // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) - const handleStopDiscussion = useCallback(async () => { - await chatAreaRef.current?.endActiveSession(); - doSessionCleanup(); - }, [doSessionCleanup]); - - // Single source of truth for "is the current scene editable?" — feeds both - // the Pro toggle's disabled state in the Header and the auto-exit effect - // below. Using one predicate keeps the header and auto-exit in lock-step so - // we never offer a click that would immediately auto-exit. + // Predicate for "can the user enter Pro mode for the current scene?". + // Single source of truth feeds the Header's Pro Switch state and the + // auto-exit effect below; keeping them in lock-step prevents an + // edit-mode entry that would immediately auto-exit. const isEditable = isCurrentSceneEditable({ currentSceneId, sceneCount: scenes.length, @@ -282,1080 +49,65 @@ export function Stage({ hasCurrentScene: !!currentScene, }); - // Cross-tab edit lock (#571). Course identity is the stage id; degrades - // to single-tab when absent. Wired here because entry must be refused - // BEFORE the live session is torn down. + // Cross-tab edit lock (#571). Lives at this layer because entry must + // be refused BEFORE the live session is torn down; PlaybackChromeRoot + // can't own this since it can't refuse its own unmount path. const editLock = useEditModeLock(stage?.id); - // Toggle Pro (edit) mode. Entering edit mode tears down any live - // session so the user enters a quiet canvas. + const playbackRef = useRef(null); + + // Pro Switch handler. Edit→playback is a plain flip (PlaybackChromeRoot + // will mount fresh; its engine effect re-inits). Playback→edit must + // (1) refuse on lock conflict, (2) await SSE / engine / TTS teardown + // so PlaybackChromeRoot is quiescent before it unmounts. const handleToggleEditMode = useCallback(async () => { if (mode === 'edit') { setMode('playback'); return; } - // Refuse entry (and surface the conflict prompt) when another tab - // already holds this course's edit lock — before any teardown. if (!editLock.acquire()) return; - await chatAreaRef.current?.endActiveSession(); - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - discussionAbortRef.current = null; - } - engineRef.current?.stop(); - discussionTTS.cleanup(); - resetSceneState(); + await playbackRef.current?.teardown(); setMode('edit'); - }, [discussionTTS, editLock, mode, resetSceneState, setMode]); + }, [editLock, mode, setMode]); - // Auto-exit edit mode whenever the current scene becomes uneditable - // (pending generation, no scenes, currently generating). Predicate lives in - // lib/edit/stage-mode so it can be unit-tested without rendering Stage. + // Auto-exit edit mode when the current scene becomes uneditable + // (pending generation, no scenes, currently generating). useEffect(() => { if (mode === 'edit' && !isEditable) { setMode('playback'); } }, [mode, isEditable, setMode]); - // Release the cross-tab lock whenever we are not in edit mode. Covers - // manual exit, the auto-exit above, and scene-becomes-uneditable; the - // hook also self-releases on unmount / tab close. + // Release the lock whenever we're not in edit mode (covers manual + // exit, auto-exit, scene becomes uneditable). The hook also self- + // releases on unmount / tab close. const releaseEditLock = editLock.release; useEffect(() => { if (mode !== 'edit') releaseEditLock(); }, [mode, releaseEditLock]); - const clearPresentationIdleTimer = useCallback(() => { - if (presentationIdleTimerRef.current) { - clearTimeout(presentationIdleTimerRef.current); - presentationIdleTimerRef.current = null; - } - }, []); - - const resetPresentationIdleTimer = useCallback(() => { - setControlsVisible(true); - clearPresentationIdleTimer(); - if (isPresenting && !isPresentationInteractionActive) { - presentationIdleTimerRef.current = setTimeout(() => { - setControlsVisible(false); - }, 3000); - } - }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]); - - const togglePresentation = useCallback(async () => { - const stageElement = stageRef.current; - if (!stageElement) return; - - try { - if (document.fullscreenElement === stageElement) { - // Unlock Escape key before exiting fullscreen - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (navigator as any).keyboard?.unlock?.(); - await document.exitFullscreen(); - return; - } - - setControlsVisible(true); - await stageElement.requestFullscreen(); - // Lock Escape key so it doesn't auto-exit fullscreen (#255) - // Escape is handled manually in our keydown handler instead - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); - setSidebarCollapsed(true); - setChatAreaCollapsed(true); - } catch { - // Firefox may deny fullscreen from certain keyboard events (e.g. F11) - console.warn('[Presentation] Fullscreen request denied — browser policy'); - } - }, [setChatAreaCollapsed, setSidebarCollapsed]); - - useEffect(() => { - const onFullscreenChange = () => { - const active = document.fullscreenElement === stageRef.current; - setIsPresenting(active); - - if (!active) { - // Ensure keyboard unlock on any fullscreen exit - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (navigator as any).keyboard?.unlock?.(); - setControlsVisible(true); - clearPresentationIdleTimer(); - } - }; - - document.addEventListener('fullscreenchange', onFullscreenChange); - return () => document.removeEventListener('fullscreenchange', onFullscreenChange); - }, [clearPresentationIdleTimer]); - - useEffect(() => { - if (!isPresenting) { - setControlsVisible(true); - clearPresentationIdleTimer(); - return; - } - - const handleActivity = () => { - resetPresentationIdleTimer(); - }; - - window.addEventListener('mousemove', handleActivity); - window.addEventListener('mousedown', handleActivity); - window.addEventListener('touchstart', handleActivity); - if (isPresentationInteractionActive) { - setControlsVisible(true); - clearPresentationIdleTimer(); - } else { - resetPresentationIdleTimer(); - } - - return () => { - window.removeEventListener('mousemove', handleActivity); - window.removeEventListener('mousedown', handleActivity); - window.removeEventListener('touchstart', handleActivity); - clearPresentationIdleTimer(); - }; - }, [ - clearPresentationIdleTimer, - isPresenting, - isPresentationInteractionActive, - resetPresentationIdleTimer, - ]); - - // Initialize playback engine when scene changes - useEffect(() => { - // Bump epoch so any stale SSE callbacks from the previous scene are discarded - sceneEpochRef.current++; - - // End any active QA/discussion session — this synchronously aborts the SSE - // stream inside use-chat-sessions (abortControllerRef.abort()), preventing - // stale onLiveSpeech callbacks from leaking into the new scene. - chatAreaRef.current?.endActiveSession(); - - // Also abort the engine-level discussion controller - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - discussionAbortRef.current = null; - } - - // Stop any in-flight discussion TTS audio on scene switch - discussionTTS.cleanup(); - - // Reset all roundtable/live state so scenes are fully isolated - resetSceneState(); - - if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) { - engineRef.current = null; - setEngineMode('idle'); - - return; - } - - // Stop previous engine - if (engineRef.current) { - engineRef.current.stop(); - } - - // Get widget iframe messaging callback for interactive scenes (keyed by sceneId) - const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id); - - // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging) - const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current, widgetSendMessage); - - // Create new PlaybackEngine - const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, { - onModeChange: (mode) => { - setEngineMode(mode); - }, - onSceneChange: (_sceneId) => { - // Scene change handled by engine - }, - onSpeechStart: (text) => { - setLectureSpeech(text); - // Add to lecture session with incrementing index for dedup - // Chat area pacing is handled by the StreamBuffer (onTextReveal) - if (lectureSessionIdRef.current) { - const idx = lectureActionCounterRef.current++; - const speechId = `speech-${Date.now()}`; - chatAreaRef.current?.addLectureMessage( - lectureSessionIdRef.current, - { id: speechId, type: 'speech', text } as Action, - idx, - ); - // Track active bubble for highlight (Issue 8) - const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!); - if (msgId) setActiveBubbleId(msgId); - } - }, - onSpeechEnd: () => { - // Don't clear lectureSpeech — let it persist until the next - // onSpeechStart replaces it or the scene transitions. - // Clearing here causes fallback to idleText (first sentence). - setActiveBubbleId(null); - }, - onEffectFire: (effect: Effect) => { - // Add to lecture session with incrementing index - if ( - lectureSessionIdRef.current && - (effect.kind === 'spotlight' || effect.kind === 'laser') - ) { - const idx = lectureActionCounterRef.current++; - chatAreaRef.current?.addLectureMessage( - lectureSessionIdRef.current, - { - id: `${effect.kind}-${Date.now()}`, - type: effect.kind, - elementId: effect.targetId, - } as Action, - idx, - ); - } - }, - onProactiveShow: (trigger) => { - if (!trigger.agentId) { - // Mutate in-place so engine.currentTrigger also gets the agentId - // (confirmDiscussion reads agentId from the same object reference) - trigger.agentId = pickStudentAgent(); - } - setDiscussionTrigger(trigger); - }, - onProactiveHide: () => { - setDiscussionTrigger(null); - }, - onDiscussionConfirmed: (topic, prompt, agentId) => { - // Start SSE discussion via ChatArea - handleDiscussionSSE(topic, prompt, agentId); - }, - onDiscussionEnd: () => { - // Abort any active SSE - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - discussionAbortRef.current = null; - } - setDiscussionTrigger(null); - // Stop any in-flight discussion TTS audio - discussionTTS.cleanup(); - // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup) - resetLiveState(); - // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup) - if (!manualStopRef.current) { - setEndFlashSessionType('discussion'); - setShowEndFlash(true); - setTimeout(() => setShowEndFlash(false), 1800); - } - // If all actions are exhausted (discussion was the last action), mark - // playback as completed so the bubble shows reset instead of play. - if (engineRef.current?.isExhausted()) { - setPlaybackCompleted(true); - } - }, - onUserInterrupt: (text) => { - // User interrupted → start a discussion via chat - chatAreaRef.current?.sendMessage(text); - }, - isAgentSelected: (agentId) => { - const ids = useSettingsStore.getState().selectedAgentIds; - return ids.includes(agentId); - }, - getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, - onComplete: () => { - // lectureSpeech intentionally NOT cleared — last sentence stays visible - // until scene transition (auto-play) or user restarts. Scene change - // effect handles the reset. - setPlaybackCompleted(true); - - // End lecture session on playback complete - if (lectureSessionIdRef.current) { - chatAreaRef.current?.endSession(lectureSessionIdRef.current); - lectureSessionIdRef.current = null; - } - // Auto-play: advance to next scene after a short pause - const { autoPlayLecture } = useSettingsStore.getState(); - if (autoPlayLecture) { - setTimeout(() => { - const stageState = useStageStore.getState(); - if (!useSettingsStore.getState().autoPlayLecture) return; - const allScenes = stageState.scenes; - const curId = stageState.currentSceneId; - const idx = allScenes.findIndex((s) => s.id === curId); - if (idx >= 0 && idx < allScenes.length - 1) { - const currentScene = allScenes[idx]; - if ( - currentScene.type === 'quiz' || - currentScene.type === 'interactive' || - currentScene.type === 'pbl' - ) { - return; - } - autoStartRef.current = true; - stageState.setCurrentSceneId(allScenes[idx + 1].id); - } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) { - // Last scene exhausted but next is still generating — go to pending page - const currentScene = allScenes[idx]; - if ( - currentScene.type === 'quiz' || - currentScene.type === 'interactive' || - currentScene.type === 'pbl' - ) { - return; - } - autoStartRef.current = true; - stageState.setCurrentSceneId(PENDING_SCENE_ID); - } - }, 1500); - } - }, - }); - - engineRef.current = engine; - - // Auto-start if triggered by auto-play scene advance - if (autoStartRef.current) { - autoStartRef.current = false; - (async () => { - if (currentScene && chatAreaRef.current) { - const sessionId = await chatAreaRef.current.startLecture(currentScene.id); - lectureSessionIdRef.current = sessionId; - lectureActionCounterRef.current = 0; - } - engine.start(); - })(); - } else { - // Load saved playback state and restore position (but never auto-play). - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs - }, [currentScene]); - - // Cleanup on unmount - useEffect(() => { - const audioPlayer = audioPlayerRef.current; - const chatArea = chatAreaRef.current; - return () => { - if (engineRef.current) { - engineRef.current.stop(); - } - audioPlayer.destroy(); - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - } - discussionTTS.cleanup(); - chatArea?.endActiveSession(); - clearPresentationIdleTimer(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable - }, []); - - // Sync mute state from settings store to audioPlayer - useEffect(() => { - audioPlayerRef.current.setMuted(ttsMuted); - }, [ttsMuted]); - - // Sync volume from settings store to audioPlayer - const ttsVolume = useSettingsStore((s) => s.ttsVolume); - useEffect(() => { - if (!ttsMuted) { - audioPlayerRef.current.setVolume(ttsVolume); - } - }, [ttsVolume, ttsMuted]); - - // Sync playback speed to audio player (for live-updating current audio) - const playbackSpeed = useSettingsStore((s) => s.playbackSpeed); - useEffect(() => { - audioPlayerRef.current.setPlaybackRate(playbackSpeed); - }, [playbackSpeed]); - - /** - * Handle discussion SSE — POST /api/chat and push events to engine - */ - const handleDiscussionSSE = useCallback( - async (topic: string, prompt?: string, agentId?: string) => { - // Start discussion display in ChatArea (lecture speech is preserved independently) - chatAreaRef.current?.startDiscussion({ - topic, - prompt, - agentId: agentId || 'default-1', - }); - // Auto-switch to chat tab when discussion starts - chatAreaRef.current?.switchToTab('chat'); - // Immediately mark streaming for synchronized stop button - setChatIsStreaming(true); - setChatSessionType('discussion'); - // Optimistic thinking: show thinking dots immediately (same as onMessageSend) - setThinkingState({ stage: 'director' }); - }, - [], - ); - - // First speech text for idle display (extracted here for playbackView) - const firstSpeechText = useMemo( - () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null, - [currentScene], - ); - - // Whether the speaking agent is a student (for bubble role derivation) - const speakingStudentFlag = useMemo(() => { - if (!speakingAgentId) return false; - const agent = useAgentRegistry.getState().getAgent(speakingAgentId); - return agent?.role !== 'teacher'; - }, [speakingAgentId]); - - // Centralised derived playback view - const playbackView = useMemo( - () => - computePlaybackView({ - engineMode, - lectureSpeech, - liveSpeech, - speakingAgentId, - thinkingState, - isCueUser, - isTopicPending, - chatIsStreaming, - discussionTrigger, - playbackCompleted, - idleText: firstSpeechText, - speakingStudent: speakingStudentFlag, - sessionType: chatSessionType, - }), - [ - engineMode, - lectureSpeech, - liveSpeech, - speakingAgentId, - thinkingState, - isCueUser, - isTopicPending, - chatIsStreaming, - discussionTrigger, - playbackCompleted, - firstSpeechText, - speakingStudentFlag, - chatSessionType, - ], - ); - - const isTopicActive = playbackView.isTopicActive; - - /** - * Gated scene switch — if a topic is active, show AlertDialog before switching. - * Returns true if the switch was immediate, false if gated (dialog shown). - */ - const gatedSceneSwitch = useCallback( - (targetSceneId: string): boolean => { - if (targetSceneId === currentSceneId) return false; - if (isTopicActive) { - setPendingSceneId(targetSceneId); - return false; - } - setCurrentSceneId(targetSceneId); - return true; - }, - [currentSceneId, isTopicActive, setCurrentSceneId], - ); - - /** User confirmed scene switch via AlertDialog */ - const confirmSceneSwitch = useCallback(() => { - if (!pendingSceneId) return; - chatAreaRef.current?.endActiveSession(); - doSessionCleanup(); - setCurrentSceneId(pendingSceneId); - setPendingSceneId(null); - }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]); - - /** User cancelled scene switch via AlertDialog */ - const cancelSceneSwitch = useCallback(() => { - setPendingSceneId(null); - }, []); - - // play/pause toggle - const handlePlayPause = useCallback(async () => { - const engine = engineRef.current; - if (!engine) return; - - const mode = engine.getMode(); - if (mode === 'playing' || mode === 'live') { - engine.pause(); - // Pause lecture buffer so text stops immediately - if (lectureSessionIdRef.current) { - chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current); - } - } else if (mode === 'paused') { - engine.resume(); - // Resume lecture buffer - if (lectureSessionIdRef.current) { - chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current); - } - } else { - const wasCompleted = playbackCompleted; - setPlaybackCompleted(false); - // Starting playback - create/reuse lecture session - if (currentScene && chatAreaRef.current) { - const sessionId = await chatAreaRef.current.startLecture(currentScene.id); - lectureSessionIdRef.current = sessionId; - } - if (wasCompleted) { - // Restart from beginning (user clicked restart after completion) - lectureActionCounterRef.current = 0; - engine.start(); - } else { - // Continue from current position (e.g. after discussion end) - engine.continuePlayback(); - } - } - }, [playbackCompleted, currentScene]); - - // get scene information - const isPendingScene = currentSceneId === PENDING_SCENE_ID; - const hasNextPending = generatingOutlines.length > 0; - // True when every outline has materialized into a scene and nothing is - // currently generating — signals the classroom has finished and the user - // can see a completion page. Comparing scenes.length === outlines.length - // (rather than just `scenes.length > 0`) means a partial generation with - // some failed outlines does not falsely trigger completion. - const isCourseComplete = - outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0; - const canAdvanceToPendingSlot = hasNextPending || isCourseComplete; - - // previous scene (gated) - const handlePreviousScene = useCallback(() => { - if (isPendingScene) { - // From pending page → go to last real scene - if (scenes.length > 0) { - gatedSceneSwitch(scenes[scenes.length - 1].id); - } - return; - } - const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); - if (currentIndex > 0) { - gatedSceneSwitch(scenes[currentIndex - 1].id); - } - }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]); - - // next scene (gated) - const handleNextScene = useCallback(() => { - if (isPendingScene) return; // Already on pending, nowhere to go - const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); - if (currentIndex < scenes.length - 1) { - gatedSceneSwitch(scenes[currentIndex + 1].id); - } else if (canAdvanceToPendingSlot) { - // On last real scene → advance to pending slot (generating or completion page) - setCurrentSceneId(PENDING_SCENE_ID); - } - }, [ - currentSceneId, - gatedSceneSwitch, - canAdvanceToPendingSlot, - isPendingScene, - scenes, - setCurrentSceneId, - ]); - - const currentSceneIndex = isPendingScene - ? scenes.length - : scenes.findIndex((s) => s.id === currentSceneId); - const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0); - - // get action information - const totalActions = currentScene?.actions?.length || 0; - - // whiteboard toggle - const handleWhiteboardToggle = () => { - setWhiteboardOpen(!whiteboardOpen); - }; - - const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => { - if (!(target instanceof HTMLElement)) return false; - - if (target.isContentEditable || target.closest('[contenteditable="true"]')) { - return true; - } - - return ( - target.closest( - ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '), - ) !== null - ); - }, []); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser - if (event.ctrlKey || event.metaKey || event.altKey) return; - if ( - isPresentationShortcutTarget(event.target) || - isPresentationShortcutTarget(document.activeElement) - ) { - return; - } - - switch (event.key) { - case 'ArrowLeft': - if (!isPresenting) return; - event.preventDefault(); - handlePreviousScene(); - resetPresentationIdleTimer(); - break; - case 'ArrowRight': - if (!isPresenting) return; - event.preventDefault(); - handleNextScene(); - resetPresentationIdleTimer(); - break; - case ' ': - case 'Spacebar': - // During active QA/discussion, Roundtable owns Space for - // buffer-level pause/resume — don't also fire engine play/pause. - if (chatSessionType === 'qa' || chatSessionType === 'discussion') break; - event.preventDefault(); - handlePlayPause(); - break; - case 'Escape': - // With keyboard.lock(), Escape no longer auto-exits fullscreen. - // If panels are open, roundtable handles Escape (close panels). - // If no panels are open, manually exit fullscreen. - if (isPresenting && !isPresentationInteractionActive) { - event.preventDefault(); - togglePresentation(); - } - break; - case 'ArrowUp': - event.preventDefault(); - setTTSVolume(ttsVolume + 0.1); - break; - case 'ArrowDown': - event.preventDefault(); - setTTSVolume(ttsVolume - 0.1); - break; - case 'm': - case 'M': - event.preventDefault(); - setTTSMuted(!ttsMuted); - break; - case 's': - case 'S': - event.preventDefault(); - setSidebarCollapsed(!sidebarCollapsed); - break; - case 'c': - case 'C': - event.preventDefault(); - setChatAreaCollapsed(!chatAreaCollapsed); - break; - default: - break; - } - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [ - chatSessionType, - chatAreaCollapsed, - handleNextScene, - handlePlayPause, - handlePreviousScene, - isPresenting, - isPresentationInteractionActive, - isPresentationShortcutTarget, - resetPresentationIdleTimer, - setChatAreaCollapsed, - setSidebarCollapsed, - setTTSMuted, - setTTSVolume, - sidebarCollapsed, - togglePresentation, - ttsMuted, - ttsVolume, - ]); - - // Intercept F11 to use our presentation fullscreen instead of browser fullscreen - // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit) - useEffect(() => { - const onF11 = (event: KeyboardEvent) => { - if (event.key === 'F11') { - event.preventDefault(); - togglePresentation(); - } - }; - - window.addEventListener('keydown', onF11); - return () => window.removeEventListener('keydown', onF11); - }, [togglePresentation]); - - // Map engine mode to the CanvasArea's expected engine state - const canvasEngineState = (() => { - switch (engineMode) { - case 'playing': - case 'live': - return 'playing'; - case 'paused': - return 'paused'; - default: - return 'idle'; - } - })(); - - // Build discussion request for Roundtable ProactiveCard from trigger - const discussionRequest: DiscussionAction | null = discussionTrigger - ? { - type: 'discussion', - id: discussionTrigger.id, - topic: discussionTrigger.question, - prompt: discussionTrigger.prompt, - agentId: discussionTrigger.agentId || 'default-1', - } - : null; - - // Calculate scene viewer height (subtract Header's 80px height) - const sceneViewerHeight = (() => { - const headerHeight = isPresenting ? 0 : 80; // Header h-20 = 80px - const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0; - return `calc(100% - ${headerHeight + roundtableHeight}px)`; - })(); + const toggleHandler = isMaicEditorEnabled() ? handleToggleEditMode : undefined; return ( -
- {/* Sidebar — only rendered on the playback / autonomous path. The - Pro mode v0 leaves the canvas full-width; a future PR will plug a - redesigned slide navigator into EditShell's leftRail slot. */} - {mode !== 'edit' && ( - + {mode === 'edit' && currentScene ? ( + + ) : ( + )} - - {/* Main Content Area */} -
- {/* Header */} - {!isPresenting && ( -
- )} - - {/* Canvas Area — Pro mode replaces CanvasArea with EditShell so the - edit chrome (CommandBar + canvas + floating toolbar + hint rail) - takes the same slot. Header above stays mounted because it owns - the global Pro toggle: exiting Pro mode closes the Switch in - Header (no dedicated Done-editing button). */} -
- {mode === 'edit' && currentScene ? ( - - ) : ( - setSidebarCollapsed(!sidebarCollapsed)} - onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} - onPrevSlide={handlePreviousScene} - onNextSlide={handleNextScene} - onPlayPause={handlePlayPause} - onWhiteboardClose={handleWhiteboardToggle} - isPresenting={isPresenting} - onTogglePresentation={togglePresentation} - showStopDiscussion={ - engineMode === 'live' || - (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) - } - onStopDiscussion={handleStopDiscussion} - hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)} - isPendingScene={isPendingScene} - isCourseComplete={isCourseComplete} - isGenerationFailed={ - isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id) - } - onRetryGeneration={ - onRetryOutline && generatingOutlines[0] - ? () => onRetryOutline(generatingOutlines[0].id) - : undefined - } - /> - )} - -
- - {/* Roundtable Area */} - {mode === 'playback' && ( -
- { - // Always clear Level-1 pause state — the closure may hold a stale - // isDiscussionPaused value (e.g. voice input's onTranscription callback - // captures onMessageSend before React re-renders with the updated state). - setIsDiscussionPaused(false); - // Clear the sticky livePausedRef so the next agent-loop buffer - // starts unpaused. (pauseActiveLiveBuffer sets a ref that new - // buffers inherit — must be cleared before sendMessage creates one.) - chatAreaRef.current?.resumeActiveLiveBuffer(); - // Flush any buffered / in-flight TTS audio from the previous - // agent turn so it doesn't leak into the next round. - discussionTTS.cleanup(); - // Clear soft-paused state — user is continuing the topic - if (isTopicPending) { - setIsTopicPending(false); - setLiveSpeech(null); - setSpeakingAgentId(null); - } - // User interrupts during playback — handleUserInterrupt triggers - // onUserInterrupt callback which already calls sendMessage, so skip - // the direct sendMessage below to avoid sending twice. - // Include 'paused' because onInputActivate pauses the engine before - // the user finishes typing — without this the interrupt position - // would never be saved and resuming after QA skips to the next sentence. - if ( - engineRef.current && - (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused') - ) { - engineRef.current.handleUserInterrupt(msg); - } else { - chatAreaRef.current?.sendMessage(msg); - } - // Auto-switch to chat tab when user sends a message - chatAreaRef.current?.switchToTab('chat'); - setIsCueUser(false); - // Immediately mark streaming for synchronized stop button - setChatIsStreaming(true); - setChatSessionType(chatSessionType || 'qa'); - // Optimistic thinking: show thinking dots immediately so there's - // no blank gap between userMessage expiry and the SSE thinking event. - // The real SSE event will overwrite this with the same or updated value. - setThinkingState({ stage: 'director' }); - }} - onDiscussionStart={() => { - // User clicks "Join" on ProactiveCard - engineRef.current?.confirmDiscussion(); - }} - onDiscussionSkip={() => { - // User clicks "Skip" on ProactiveCard - engineRef.current?.skipDiscussion(); - }} - onStopDiscussion={handleStopDiscussion} - onInputActivate={() => { - // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering. - // User resumes manually via Space / pause button after closing the input. - // No isDiscussionPaused guard — always attempt to pause the buffer. - // The return value ensures UI state stays in sync with buffer state. - if (chatSessionType === 'qa' || chatSessionType === 'discussion') { - const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); - if (paused) { - discussionTTS.pause(); - setIsDiscussionPaused(true); - } - } - // Also pause playback engine - if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { - engineRef.current.pause(); - } - }} - onResumeTopic={doResumeTopic} - onPlayPause={handlePlayPause} - isDiscussionPaused={isDiscussionPaused} - onDiscussionPause={() => { - const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); - if (paused) { - discussionTTS.pause(); - setIsDiscussionPaused(true); - } - }} - onDiscussionResume={() => { - chatAreaRef.current?.resumeActiveLiveBuffer(); - discussionTTS.resume(); - setIsDiscussionPaused(false); - }} - totalActions={totalActions} - currentActionIndex={0} - currentSceneIndex={currentSceneIndex} - scenesCount={totalScenesCount} - whiteboardOpen={whiteboardOpen} - sidebarCollapsed={sidebarCollapsed} - chatCollapsed={chatAreaCollapsed} - onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} - onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} - onPrevSlide={handlePreviousScene} - onNextSlide={handleNextScene} - onWhiteboardClose={handleWhiteboardToggle} - isPresenting={isPresenting} - controlsVisible={controlsVisible} - onTogglePresentation={togglePresentation} - onPresentationInteractionChange={setIsPresentationInteractionActive} - fullscreenContainerRef={stageRef} - /> -
- )} -
- - {/* Chat Area — only rendered on the playback / autonomous path. Pro (edit) - mode replaces CanvasArea with EditShell in the same slot; ChatArea - and the playback sidebar are simply not mounted in that state. */} - {mode !== 'edit' && ( -
- setActiveBubbleId(id)} - currentSceneId={currentSceneId} - onLiveSpeech={(text, agentId) => { - // Capture epoch at call time — discard if scene has changed since - const epoch = sceneEpochRef.current; - // Use queueMicrotask to let any pending scene-switch reset settle first - queueMicrotask(() => { - if (sceneEpochRef.current !== epoch) return; // stale — scene changed - setLiveSpeech(text); - if (agentId !== undefined) { - setSpeakingAgentId(agentId); - } - if (text !== null || agentId) { - setChatIsStreaming(true); - setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null); - setIsTopicPending(false); - } else if (text === null && agentId === null) { - setChatIsStreaming(false); - // Don't clear chatSessionType here — it's needed by the stop - // button when director cues user (cue_user → done → liveSpeech null). - // It gets properly cleared in doSessionCleanup and scene change. - } - }); - }} - onSpeechProgress={(ratio) => { - const epoch = sceneEpochRef.current; - queueMicrotask(() => { - if (sceneEpochRef.current !== epoch) return; - setSpeechProgress(ratio); - }); - }} - onThinking={(state) => { - const epoch = sceneEpochRef.current; - queueMicrotask(() => { - if (sceneEpochRef.current !== epoch) return; - setThinkingState(state); - }); - }} - onCueUser={(_fromAgentId, _prompt) => { - setIsCueUser(true); - }} - onLiveSessionError={handleLiveSessionError} - onStopSession={doSessionCleanup} - onSegmentSealed={discussionTTS.handleSegmentSealed} - shouldHoldAfterReveal={discussionTTS.shouldHold} - /> -
- )} - - {/* Scene switch confirmation dialog */} - { - if (!open) cancelSceneSwitch(); - }} - > - - - {t('stage.confirmSwitchTitle')} - - {/* Top accent bar */} -
- -
- {/* Icon */} -
- -
- {/* Title */} -

- {t('stage.confirmSwitchTitle')} -

- {/* Description */} -

- {t('stage.confirmSwitchMessage')} -

-
- - - - {t('common.cancel')} - - - {t('common.confirm')} - - - - -
+ + ); } diff --git a/lib/edit/noop-surface.tsx b/lib/edit/noop-surface.tsx new file mode 100644 index 0000000000..41c0de19cd --- /dev/null +++ b/lib/edit/noop-surface.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Eye } from 'lucide-react'; +import { useMemo } from 'react'; +import { SceneRenderer } from '@/components/stage/scene-renderer'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { useStageStore } from '@/lib/store/stage'; +import type { Scene, SceneContent } from '@/lib/types/stage'; +import type { SceneEditorSurface, SurfaceState } from './scene-editor-surface'; + +/** + * NOOP_SURFACE — the read-only fallback surface used by the shell when no + * editor surface is registered for the current `scene.type` (today: quiz / + * interactive / pbl). The shell resolves `surface ?? NOOP_SURFACE`, so it + * always renders a single, structurally stable `` regardless of scene + * type. Switching from a slide to a non-slide scene therefore only swaps the + * `surface.CanvasComponent` inside the frame — `` and the + * `leftRail` slot stay mounted, eliminating the chrome remount flicker that + * the previous two-component-types branch caused. + * + * The canvas is `SceneRenderer mode="playback"` — feature-parity with the + * playback surface (interactive iframes load, quiz options render, PBL board + * paints) — plus a small "read-only" pill so the user knows why their + * formatting affordances are gone. + * + * `sceneType` is a placeholder ('slide'); NOOP_SURFACE is never `register()`d, + * only used as a fallback from `resolve(...) ?? NOOP_SURFACE`. The field is + * required by the surface contract but its value is never read in this path. + */ + +function NoopCanvas() { + const scenes = useStageStore.use.scenes(); + const currentSceneId = useStageStore.use.currentSceneId(); + const scene = useMemo( + () => scenes.find((s) => s.id === currentSceneId) ?? null, + [scenes, currentSceneId], + ); + + if (!scene) return null; + return ( + <> + + + + ); +} + +function ReadOnlyBadge({ sceneType }: { readonly sceneType: Scene['type'] }) { + const { t } = useI18n(); + return ( +
+
+ + {t('edit.readOnlyBadge', { type: t(`edit.sceneType.${sceneType}`) })} +
+
+ ); +} + +const EMPTY_STATE: SurfaceState = { + content: {} as SceneContent, + selection: undefined, + hasSelection: false, + insertItems: [], + floatingActions: [], + commands: [], + hints: [], +}; + +function useNoopSurfaceState(): SurfaceState { + // No state, no subscriptions — the chrome shows nothing surface-specific for + // read-only scene types. Returning the module-level constant keeps the hook + // signature minimal (zero internal hooks) and equality-stable across renders. + return EMPTY_STATE; +} + +export const NOOP_SURFACE: SceneEditorSurface = { + sceneType: 'slide', + CanvasComponent: NoopCanvas, + useSurfaceState: useNoopSurfaceState, +}; diff --git a/lib/edit/scene-editor-surface.ts b/lib/edit/scene-editor-surface.ts index c4838e64da..d576a01347 100644 --- a/lib/edit/scene-editor-surface.ts +++ b/lib/edit/scene-editor-surface.ts @@ -96,7 +96,12 @@ export interface SurfaceState Date: Tue, 26 May 2026 03:14:08 -0400 Subject: [PATCH 03/14] refactor(maic-editor): cross-fade chrome roots on Pro mode toggle Wrap the chrome-root dispatch in `AnimatePresence mode="wait"` with a 180 ms opacity fade-out / fade-in. The outgoing root fully exits before the incoming one mounts, so: - The single-canvasStore-writer guarantee from the chrome split is preserved (ScreenCanvas and Editor/Canvas never coexist). - Mode toggle reads as a smooth fade instead of a hard cut. Stage's outer wrapper now carries the stable `bg-gray-50 dark:bg-gray-900` background so neither root reveals raw page colour while it passes through opacity 0. `initial={false}` skips the entry animation on first mount so the initial playback render is instant. Co-Authored-By: Claude Opus 4.7 --- components/stage.tsx | 64 +++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/components/stage.tsx b/components/stage.tsx index c8ae1b2d36..7658316799 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -1,9 +1,11 @@ 'use client'; import { useCallback, useEffect, useRef } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; import { useStageStore } from '@/lib/store'; import { isCurrentSceneEditable } from '@/lib/edit/stage-mode'; import { isMaicEditorEnabled } from '@/lib/config/feature-flags'; +import { CHROME_EASE } from '@/lib/edit/transitions'; import { EditChromeRoot } from '@/components/edit/EditChromeRoot'; import { PlaybackChromeRoot, @@ -88,26 +90,56 @@ export function Stage({ const toggleHandler = isMaicEditorEnabled() ? handleToggleEditMode : undefined; + // Mode swap is wrapped in `AnimatePresence mode="wait"` so the outgoing + // chrome root fully exits (fades out) before the incoming one mounts. + // `mode="wait"` keeps the single-canvasStore-writer guarantee — the two + // canvas implementations (ScreenCanvas in playback, Editor/Canvas in + // edit) never coexist, so their `useViewportSize` writes don't compete. + // `initial={false}` skips the entry fade on the first render so the page + // loads instantly in playback mode. + // + // The outer div carries a stable background so neither side reveals the + // raw page background while it fades through opacity 0. return ( - <> - {mode === 'edit' && currentScene ? ( - - ) : ( - - )} +
+ + {mode === 'edit' && currentScene ? ( + + + + ) : ( + + + + )} + - +
); } From 97a6af6b9d94b86cd4d90683b1f2eb0de3387fd9 Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 03:20:12 -0400 Subject: [PATCH 04/14] feat(maic-editor): drawer-style mode swap transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pro toggle was a hard cut — playback chrome vanished, edit chrome popped into place. Now wraps the swap in `AnimatePresence` with the two chrome roots layered via `absolute inset-0` so they coexist for ~280ms: - Edit chrome enters from above (`translateY: -32 → 0`) + fades in, giving a "drawer drops down" feel that matches the inner CommandBar/leftRail stagger choreography. - Playback chrome cross-fades opacity-only; no transform so its active slide canvas stays put underneath while edit drops over it. Both roots keep rendering during the overlap, so `canvasStore`'s scale writer doesn't briefly read zero and snap the slide to a stale size when one root exits ahead of the other. Duration 280ms / `CHROME_EASE` matches the inner Frame timing source. Co-Authored-By: Claude Opus 4.7 --- components/stage.tsx | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/components/stage.tsx b/components/stage.tsx index 7658316799..eae4187596 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -5,7 +5,6 @@ import { AnimatePresence, motion } from 'motion/react'; import { useStageStore } from '@/lib/store'; import { isCurrentSceneEditable } from '@/lib/edit/stage-mode'; import { isMaicEditorEnabled } from '@/lib/config/feature-flags'; -import { CHROME_EASE } from '@/lib/edit/transitions'; import { EditChromeRoot } from '@/components/edit/EditChromeRoot'; import { PlaybackChromeRoot, @@ -13,6 +12,7 @@ import { } from '@/components/edit/PlaybackChromeRoot'; import { useEditModeLock } from '@/components/edit/use-edit-mode-lock'; import { MultiTabEditConflictPrompt } from '@/components/edit/MultiTabEditConflictPrompt'; +import { CHROME_EASE } from '@/lib/edit/transitions'; // Side-effect: registers the slide SceneEditorSurface so EditShell can // resolve it the moment Pro mode is entered (the shell never imports // surfaces directly). @@ -90,27 +90,23 @@ export function Stage({ const toggleHandler = isMaicEditorEnabled() ? handleToggleEditMode : undefined; - // Mode swap is wrapped in `AnimatePresence mode="wait"` so the outgoing - // chrome root fully exits (fades out) before the incoming one mounts. - // `mode="wait"` keeps the single-canvasStore-writer guarantee — the two - // canvas implementations (ScreenCanvas in playback, Editor/Canvas in - // edit) never coexist, so their `useViewportSize` writes don't compete. - // `initial={false}` skips the entry fade on the first render so the page - // loads instantly in playback mode. - // - // The outer div carries a stable background so neither side reveals the - // raw page background while it fades through opacity 0. + // Mode swap choreography — drawer feel. Edit chrome enters from above + // (translateY: -32 → 0) + fades in; playback chrome fades. Both layer + // via `absolute inset-0` so they coexist for the ~250ms cross-fade + // window without one popping out before the other arrives. The + // outgoing root keeps rendering its canvas during exit so `canvasStore` + // (the shared scale writer) doesn't briefly read zero. return ( -
- +
+ {mode === 'edit' && currentScene ? ( ) : ( Date: Tue, 26 May 2026 03:27:49 -0400 Subject: [PATCH 05/14] feat(maic-editor): Pro Switch as a shared layout element across modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pro Switch is the click anchor for the mode swap, but it lives in two different positions: the 80px playback Header (top-right) vs the 56px edit CommandBar trailing slot (also top-right but at a different y and with different padding). After the click the switch "jumped" — it visibly moved + restyled — which felt unsmooth even though the chrome itself was cross-fading. Tag the Pro Switch label (and the settings pill) with `motion.layoutId` so motion treats them as shared elements across the AnimatePresence swap. During the ~280ms transition, motion measures both instances and morphs position + size between them — the user's click target slides into its new home instead of teleporting. Same easing source as the chrome cross-fade so the two animations stay locked together. Co-Authored-By: Claude Opus 4.7 --- components/stage/header-controls.tsx | 29 +++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/components/stage/header-controls.tsx b/components/stage/header-controls.tsx index 4fd236f1de..f5e82075f9 100644 --- a/components/stage/header-controls.tsx +++ b/components/stage/header-controls.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { motion } from 'motion/react'; import { Settings, Sun, Moon, Monitor } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { useI18n } from '@/lib/hooks/use-i18n'; @@ -14,8 +15,18 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; +import { CHROME_EASE } from '@/lib/edit/transitions'; import type { StageMode } from '@/lib/types/stage'; +// Stable layout IDs used by `motion.layoutId` so the Pro Switch and the +// settings pill morph between their positions in the playback Header and +// the edit-mode CommandBar (instead of "jumping" when the mode swap +// re-renders the trees). The transition is automatic — motion measures +// both instances and animates the shared element across the gap. +const PRO_SWITCH_LAYOUT_ID = 'maic-pro-switch'; +const SETTINGS_PILL_LAYOUT_ID = 'maic-settings-pill'; +const SHARED_LAYOUT_TRANSITION = { duration: 0.28, ease: CHROME_EASE } as const; + interface HeaderControlsProps { readonly mode?: StageMode; readonly canEdit?: boolean; @@ -56,7 +67,9 @@ export function HeaderControls({ return ( <> -
-
+
{/* Pro Switch — toggle property: on/off both clickable, not a one-way "Done" button. Disabled only when the current scene - can't be entered (pending/generating/etc.). */} + can't be entered (pending/generating/etc.). + `layoutId` makes it a shared element between the playback + Header and the edit CommandBar — motion morphs its position + and size across the mode swap instead of letting the user + watch the click target jump. */} {onToggleEditMode && ( - + )} From 65c5dfdc4377c8951c6923794ec4ebf51c717822 Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 03:44:53 -0400 Subject: [PATCH 06/14] refactor(maic-editor): unify chrome shell across modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pro Switch + settings pill + download icon are the user's mode-toggle "anchors" — they need to sit at the same screen pixel across playback ↔ edit. They didn't, because the two chromes had different shapes: Playback: SceneSidebar (left, full height) | Header (h-20, 80px) on top of CanvasArea + Roundtable Edit: CommandBar (h-14, 56px, FULL width) on top of a row of (SlideNavRail | content), so the rail sat *below* the bar So when the user clicked Pro, the bar collapsed by 24px AND the rail shifted down by 56px AND the right-side controls re-styled (compact variant) — three simultaneous moves. layoutId masked some of it but the underlying structure was wrong. Unify the shells: - `StageGrid` template flipped from `top top top / left center right / bottom bottom bottom` to `left top top / left center right / left bottom bottom`. The left column now spans all rows so the sidebar always reaches the absolute top edge, matching playback exactly. - `CommandBar` grows h-14 → h-20 + px-5 → px-8, identical to playback Header. - `EditChromeRoot` drops the `variant="compact"` flag on `HeaderControls` so the settings pill renders at the same h-9 pill it does in playback. - `SlideNavRail` header replaces the "SCENES" label with the OpenMAIC logo (click → home), matching `SceneSidebar`'s shape so the sidebar top reads as the same component family in both modes. - Download / Export dropdown moves out of `Header` and into `HeaderControls` so it's present in both playback and edit chrome at the same right-cluster position (was previously playback-only). `Header.tsx` slimmed accordingly. Net effect: the right-edge cluster (EN, theme, settings, download, Pro Switch) lives at the same screen pixel across modes; the cross-fade transition only animates the *contents* inside the bars + sidebar lists, not the bar/rail positions themselves. Co-Authored-By: Claude Opus 4.7 --- components/edit/EditChromeRoot.tsx | 1 - components/edit/EditShell/CommandBar.tsx | 2 +- components/edit/SlideNavRail/SlideNavRail.tsx | 23 +++- components/edit/StageGrid.tsx | 22 +-- components/header.tsx | 115 +--------------- components/stage/header-controls.tsx | 129 +++++++++++++++++- 6 files changed, 159 insertions(+), 133 deletions(-) diff --git a/components/edit/EditChromeRoot.tsx b/components/edit/EditChromeRoot.tsx index ab394e1c3e..e49f6fd7cc 100644 --- a/components/edit/EditChromeRoot.tsx +++ b/components/edit/EditChromeRoot.tsx @@ -56,7 +56,6 @@ export function EditChromeRoot({ scene, isEditable, onToggleEditMode }: EditChro mode="edit" canEdit={isEditable} onToggleEditMode={isMaicEditorEnabled() ? onToggleEditMode : undefined} - variant="compact" /> } /> diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx index 41e805cbec..4bc4c34f0d 100644 --- a/components/edit/EditShell/CommandBar.tsx +++ b/components/edit/EditShell/CommandBar.tsx @@ -43,7 +43,7 @@ export function CommandBar({ title, history, insertItems, commands, trailing }: const router = useRouter(); return ( -
+
{/* Back-to-home — mirrors playback Header's leftmost button so the user has the same global-out affordance across modes. */} diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx index 21e6875949..542f8400bd 100644 --- a/components/edit/SlideNavRail/SlideNavRail.tsx +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -1,6 +1,7 @@ 'use client'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { AnimatePresence, Reorder, motion, useReducedMotion } from 'motion/react'; import { PanelLeftClose, PanelLeftOpen, PlusCircle } from 'lucide-react'; import { toast } from 'sonner'; @@ -36,6 +37,7 @@ const RAIL_MAX_PX = 360; */ export function SlideNavRail() { const { t } = useI18n(); + const router = useRouter(); const scenes = useStageStore.use.scenes(); const currentSceneId = useStageStore.use.currentSceneId(); const setCurrentSceneId = useStageStore.use.setCurrentSceneId(); @@ -270,19 +272,26 @@ export function SlideNavRail() {
)} - {/* Header band — height matches playback's logo header (h-10 + - mt-3 mb-1 — together ~56px) so when Pro mode opens the chrome - height stays consistent with what was here in playback. */} + {/* Header band — mirrors playback `SceneSidebar`: OpenMAIC logo + on the left (click → home), action cluster on the right. + Height (h-10 + mt-3 mb-1 = ~56px) matches playback so the + chrome top edge stays at the same screen pixel across the + mode swap. */}
{!collapsed && ( - - {t('edit.nav.deckLabel')} - + )}
- - {/* Export Dropdown */} -
- - {exportMenuOpen && ( -
- - - -
- )} -
); diff --git a/components/stage/header-controls.tsx b/components/stage/header-controls.tsx index f5e82075f9..e238ef175f 100644 --- a/components/stage/header-controls.tsx +++ b/components/stage/header-controls.tsx @@ -1,11 +1,25 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { motion } from 'motion/react'; -import { Settings, Sun, Moon, Monitor } from 'lucide-react'; +import { + Archive, + Download, + FileDown, + Loader2, + Monitor, + Moon, + Package, + Settings, + Sun, +} from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useTheme } from '@/lib/hooks/use-theme'; +import { useStageStore } from '@/lib/store'; +import { useMediaGenerationStore } from '@/lib/store/media-generation'; +import { useExportPPTX } from '@/lib/export/use-export-pptx'; +import { useExportClassroom } from '@/lib/export/use-export-classroom'; import { LanguageSwitcher } from '../language-switcher'; import { SettingsDialog } from '../settings'; import { @@ -63,6 +77,40 @@ export function HeaderControls({ const { theme, setTheme } = useTheme(); const [settingsOpen, setSettingsOpen] = useState(false); + // Export plumbing — uses the stage / media task stores to check + // readiness, then hands off to the export hooks. Available in both + // playback and edit chrome so the icon's screen position is stable + // across mode swaps (was previously in `Header` only, missing from + // CommandBar's right cluster). + const scenes = useStageStore((s) => s.scenes); + const generatingOutlines = useStageStore((s) => s.generatingOutlines); + const failedOutlines = useStageStore((s) => s.failedOutlines); + const mediaTasks = useMediaGenerationStore((s) => s.tasks); + const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); + const { exporting: isExportingZip, exportClassroomZip } = useExportClassroom(); + const [exportMenuOpen, setExportMenuOpen] = useState(false); + const exportRef = useRef(null); + + const canExport = + scenes.length > 0 && + generatingOutlines.length === 0 && + failedOutlines.length === 0 && + Object.values(mediaTasks).every((task) => task.status === 'done' || task.status === 'failed'); + + const handleClickOutside = useCallback( + (e: MouseEvent) => { + if (exportMenuOpen && exportRef.current && !exportRef.current.contains(e.target as Node)) { + setExportMenuOpen(false); + } + }, + [exportMenuOpen], + ); + useEffect(() => { + if (!exportMenuOpen) return; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [exportMenuOpen, handleClickOutside]); + const compact = variant === 'compact'; return ( @@ -138,6 +186,83 @@ export function HeaderControls({ > + + {/* Export — same pill, sits between settings and Pro Switch. */} +
+ + {exportMenuOpen && ( +
+ + + +
+ )} +
{/* Pro Switch — toggle property: on/off both clickable, not a From 14de204f4f089fe62d96b9664ea5359477b2f6f1 Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 08:40:07 -0400 Subject: [PATCH 07/14] feat(maic-editor): drop sidebar header + button, add insert-before-first zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header `+` was a duplicate affordance — every gap between thumbs already has its own `InsertionZone`. Remove the header button; insert flows entirely through the gap zones now (with hover-popup + and right-anchored visual). Add one extra `InsertionZone` rendered BEFORE the first thumb so the top padding of the rail is also clickable / hoverable. Insert-before- first is implemented inline via `setScenes([blank, ...scenes])` because the `insertSceneAfter` store API only handles insertion after an existing anchor. `PlusCircle` import dropped (no longer used). Co-Authored-By: Claude Opus 4.7 --- components/edit/SlideNavRail/SlideNavRail.tsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx index 542f8400bd..8ad5617744 100644 --- a/components/edit/SlideNavRail/SlideNavRail.tsx +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -3,7 +3,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { AnimatePresence, Reorder, motion, useReducedMotion } from 'motion/react'; -import { PanelLeftClose, PanelLeftOpen, PlusCircle } from 'lucide-react'; +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { useStageStore } from '@/lib/store'; @@ -143,6 +143,32 @@ export function SlideNavRail() { [currentSceneId, setCurrentSceneId], ); + /** + * Insert a fresh blank slide *before* the given scene. The first + * InsertionZone (above the first thumb) calls this with `scenes[0]` + * so it ends up at index 0 — `setScenes([blank, ...scenes])` is + * used directly there since the `insertSceneAfter` API only supports + * insertion after an existing anchor. + */ + const handleInsertBefore = useCallback( + (beforeSceneId: string) => { + if (!stage) return; + const beforeIndex = scenes.findIndex((s) => s.id === beforeSceneId); + if (beforeIndex < 0) return; + const blank = createBlankSlideScene(stage.id, t('edit.nav.untitledSlide'), beforeIndex + 1); + if (beforeIndex === 0) { + // Prepend: setScenes rebalances `order` to match the array index. + setScenes([blank, ...scenes]); + setCurrentSceneId(blank.id); + return; + } + const anchor = scenes[beforeIndex - 1]; + insertSceneAfter(anchor.id, blank); + setCurrentSceneId(blank.id); + }, + [insertSceneAfter, scenes, setCurrentSceneId, setScenes, stage, t], + ); + const handleInsertAt = useCallback( (afterSceneId: string | null) => { if (!stage) return; @@ -294,21 +320,8 @@ export function SlideNavRail() { )}
- + {/* Insertion lives in the `InsertionZone` strips between (and + before/after) thumbs now — no header `+` button. */} - - {/* Export — same pill, sits between settings and Pro Switch. */} -
- - {exportMenuOpen && ( -
- - - -
- )} -
{/* Pro Switch — toggle property: on/off both clickable, not a @@ -309,6 +232,86 @@ export function HeaderControls({ )} + {/* Export / Download — lives to the right of the Pro Switch. + Not a settings function so it does not belong inside the + settings pill; kept as a separate sibling sitting between the + Pro Switch and the right edge of the chrome. */} +
+ + {exportMenuOpen && ( +
+ + + +
+ )} +
+ ); From 3094ec16bdbd7b6d06fc3e6dc9ac9a27f253e245 Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 09:13:37 -0400 Subject: [PATCH 09/14] feat(maic-editor): floating insert toolbar above canvas (collapsible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text box / Image / future shape buttons no longer share CommandBar with global stage controls (back / undo / redo / title / settings / Pro Switch / Download). Insert is a content-creation action, not a stage-navigation one — mixing them blurred the chrome's role. Lift insert items into a new `FloatingInsertToolbar` that floats centered ~12px above the slide canvas card. Default expanded; collapse arrow tucks it into a small chevron handle at the same anchor. State persists in `settings.editInsertToolbarCollapsed`. Reuses the existing `InsertButton` (extracted from CommandBar into a sibling module so both surfaces — the now-removed CommandBar slot and the floating bar — can share styling). CommandBar drops its `insertItems` prop / middle slot entirely; right-side controls collapse to a single `flex shrink-0` cluster matching playback Header's shape. i18n: `edit.insert.expandToolbar` / `collapseToolbar` across 6 locales. Co-Authored-By: Claude Opus 4.7 --- components/edit/EditShell/CommandBar.tsx | 80 ++-------------- components/edit/EditShell/EditShell.tsx | 17 +--- .../edit/EditShell/FloatingInsertToolbar.tsx | 96 +++++++++++++++++++ components/edit/EditShell/InsertButton.tsx | 58 +++++++++++ lib/i18n/locales/ar-SA.json | 2 + lib/i18n/locales/en-US.json | 2 + lib/i18n/locales/ja-JP.json | 2 + lib/i18n/locales/ru-RU.json | 2 + lib/i18n/locales/zh-CN.json | 2 + lib/i18n/locales/zh-TW.json | 2 + lib/store/settings.ts | 5 + 11 files changed, 184 insertions(+), 84 deletions(-) create mode 100644 components/edit/EditShell/FloatingInsertToolbar.tsx create mode 100644 components/edit/EditShell/InsertButton.tsx diff --git a/components/edit/EditShell/CommandBar.tsx b/components/edit/EditShell/CommandBar.tsx index 4bc4c34f0d..5b2a4c7721 100644 --- a/components/edit/EditShell/CommandBar.tsx +++ b/components/edit/EditShell/CommandBar.tsx @@ -4,25 +4,19 @@ import { ArrowLeft, Redo2, Undo2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/hooks/use-i18n'; import { cn } from '@/lib/utils'; -import type { - EditorCommand, - InsertPaletteItem, - SurfaceHistory, -} from '@/lib/edit/scene-editor-surface'; +import type { EditorCommand, SurfaceHistory } from '@/lib/edit/scene-editor-surface'; interface CommandBarProps { readonly title: string; readonly history?: SurfaceHistory; - readonly insertItems?: readonly InsertPaletteItem[]; readonly commands?: readonly EditorCommand[]; /** * Right-edge slot owned by Stage. In Pro mode it carries the - * HeaderControls (settings pill + Pro Switch) since Stage Header is - * unmounted to keep top chrome to a single bar. + * HeaderControls (settings pill + Pro Switch + Download) since Stage + * Header is unmounted to keep top chrome to a single bar. */ readonly trailing?: ReactNode; } @@ -38,13 +32,13 @@ interface CommandBarProps { * not a one-way state, so we deliberately do *not* place a "Done" pill * here that would compete with the Switch's affordance. */ -export function CommandBar({ title, history, insertItems, commands, trailing }: CommandBarProps) { +export function CommandBar({ title, history, commands, trailing }: CommandBarProps) { const { t } = useI18n(); const router = useRouter(); return (
-
+
{/* Back-to-home — mirrors playback Header's leftmost button so the user has the same global-out affordance across modes. */} router.push('/')}> @@ -61,25 +55,14 @@ export function CommandBar({ title, history, insertItems, commands, trailing }: )} {title}
- {insertItems && insertItems.length > 0 && ( -
- {insertItems.map((item) => ( - - ))} -
- )} - -
+
{commands && commands.length > 0 && (
{commands.map((command) => ( @@ -94,59 +77,12 @@ export function CommandBar({ title, history, insertItems, commands, trailing }: ))}
)} - {trailing &&
{trailing}
} + {trailing}
); } -function InsertButton({ item }: { readonly item: InsertPaletteItem }) { - const button = ( - - ); - - const triggerWithTooltip = ( - - {button} - {item.tooltip && {item.tooltip}} - - ); - - if (!item.popoverContent) return triggerWithTooltip; - - // Chain both triggers' asChild Slots directly onto the real + + )} + +
+ ); +} diff --git a/components/edit/EditShell/InsertButton.tsx b/components/edit/EditShell/InsertButton.tsx new file mode 100644 index 0000000000..4a3f37bab8 --- /dev/null +++ b/components/edit/EditShell/InsertButton.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import type { InsertPaletteItem } from '@/lib/edit/scene-editor-surface'; + +/** + * Single insert-palette button. Reused by both the (legacy) CommandBar + * insert slot and the FloatingInsertToolbar that lives above the + * canvas now. + * + * When the item declares `popoverContent`, the button doubles as a + * popover trigger — and PopoverTrigger's `asChild` Slot is chained + * directly onto the real ` + ); + + const triggerWithTooltip = ( + + {button} + {item.tooltip && {item.tooltip}} + + ); + + if (!item.popoverContent) return triggerWithTooltip; + + return ( + + + + {button} + + {item.tooltip && {item.tooltip}} + + + {item.popoverContent()} + + + ); +} diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 5492261d1b..1f71a389c7 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -187,6 +187,8 @@ }, "insert": { "textBox": "مربع نص", + "expandToolbar": "إظهار أدوات الإدراج", + "collapseToolbar": "إخفاء أدوات الإدراج", "image": "صورة", "imageDrop": "أفلِت صورة أو انقر لاختيار ملف", "imageOr": "أو الصق رابط صورة", diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index b9c384f302..e466d451f2 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -187,6 +187,8 @@ }, "insert": { "textBox": "Text box", + "expandToolbar": "Show insert tools", + "collapseToolbar": "Hide insert tools", "image": "Image", "imageDrop": "Drop an image or click to choose a file", "imageOr": "or paste an image URL", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index cf49afa111..d13cfa3c59 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -187,6 +187,8 @@ }, "insert": { "textBox": "テキストボックス", + "expandToolbar": "挿入ツールを表示", + "collapseToolbar": "挿入ツールを隠す", "image": "画像", "imageDrop": "画像をドロップまたはクリックして選択", "imageOr": "または画像 URL を貼り付け", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index f588487dde..444fb96819 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -187,6 +187,8 @@ }, "insert": { "textBox": "Текстовое поле", + "expandToolbar": "Показать инструменты вставки", + "collapseToolbar": "Скрыть инструменты вставки", "image": "Изображение", "imageDrop": "Перетащите изображение или нажмите, чтобы выбрать файл", "imageOr": "или вставьте URL изображения", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 0b13080b39..2ce6ada751 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -187,6 +187,8 @@ }, "insert": { "textBox": "文本框", + "expandToolbar": "展开插入工具", + "collapseToolbar": "收起插入工具", "image": "图片", "imageDrop": "拖入图片或点击选择文件", "imageOr": "或粘贴图片 URL", diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 3eedf4376d..325e1fe04a 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -187,6 +187,8 @@ }, "insert": { "textBox": "文字方塊", + "expandToolbar": "展開插入工具", + "collapseToolbar": "收起插入工具", "image": "圖片", "imageDrop": "拖曳圖片或點按選擇檔案", "imageOr": "或貼上圖片網址", diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 54f17004f5..6df3578661 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -195,6 +195,7 @@ export interface SettingsState { chatAreaWidth: number; editRailCollapsed: boolean; editRailWidth: number; + editInsertToolbarCollapsed: boolean; // Actions setModel: (providerId: ProviderId, modelId: string) => void; @@ -220,6 +221,7 @@ export interface SettingsState { setChatAreaCollapsed: (collapsed: boolean) => void; setChatAreaWidth: (width: number) => void; setEditRailCollapsed: (collapsed: boolean) => void; + setEditInsertToolbarCollapsed: (collapsed: boolean) => void; setEditRailWidth: (width: number) => void; // Audio actions @@ -726,6 +728,7 @@ export const useSettingsStore = create()( chatAreaWidth: 320, editRailCollapsed: false, editRailWidth: 220, + editInsertToolbarCollapsed: false, // Audio settings (use defaults) ...defaultAudioConfig, @@ -810,6 +813,8 @@ export const useSettingsStore = create()( setChatAreaCollapsed: (collapsed) => set({ chatAreaCollapsed: collapsed }), setEditRailCollapsed: (collapsed) => set({ editRailCollapsed: collapsed }), setEditRailWidth: (width) => set({ editRailWidth: width }), + setEditInsertToolbarCollapsed: (collapsed) => + set({ editInsertToolbarCollapsed: collapsed }), setChatAreaWidth: (width) => set({ chatAreaWidth: width }), // Audio actions From 51143cdfe08f76c3d252fe8f0440c61c0297eacd Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 09:23:22 -0400 Subject: [PATCH 10/14] fix(maic-editor): auto-focus text element after toolbar insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserting a Text box via the FloatingInsertToolbar + click/drag on the canvas left the user one click short — the new element was selected and the AnchoredTextBar opened, but the ProseMirror editor never received focus, so the first keystroke went nowhere and the user had to click inside the element again before typing. `useEditingTextElementId` already mirrors the surface's editing-target choice into `canvasStore.editingElementId`. Have `ProsemirrorEditor` watch that flag in an effect: whenever its own elementId becomes the editing target (insert, programmatic selection, etc.) and it doesn't already have focus, push focus into the view. `hasFocus()` guard keeps this from re-focusing on every re-render of an already-active editor. Co-Authored-By: Claude Opus 4.7 --- .../components/element/ProsemirrorEditor.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/components/slide-renderer/components/element/ProsemirrorEditor.tsx b/components/slide-renderer/components/element/ProsemirrorEditor.tsx index 0f04fbb49a..d100d13e3f 100644 --- a/components/slide-renderer/components/element/ProsemirrorEditor.tsx +++ b/components/slide-renderer/components/element/ProsemirrorEditor.tsx @@ -77,6 +77,7 @@ export const ProsemirrorEditor = forwardRef void) | null>(null); const handleElementId = useCanvasStore.use.handleElementId(); + const editingElementId = useCanvasStore.use.editingElementId(); const textFormatPainter = useCanvasStore.use.textFormatPainter(); const richTextAttrs = useCanvasStore.use.richTextAttrs(); const activeElementIdList = useCanvasStore.use.activeElementIdList(); @@ -515,6 +516,22 @@ export const ProsemirrorEditor = forwardRef { + if (!editable) return; + if (editingElementId !== elementId) return; + const view = editorView.current; + if (!view || view.hasFocus()) return; + view.focus(); + }, [editingElementId, elementId, editable]); + // Expose focus method useImperativeHandle(ref, () => ({ focus: () => { From 396bcadec481fd116b37add8a30ef8d5cfe6cc8c Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 09:32:48 -0400 Subject: [PATCH 11/14] chore(maic-editor): prettier + drop ThumbItem rename sync effect - prettier --write on three files touched by the recent edits. - ThumbItem: drop the `useEffect(() => { if (!renaming) setDraft(...) })` external-title sync that tripped `react-hooks/set-state-in-effect`. Idle display now reads from `scene.title` directly (derived rather than mirrored); `startRename` seeds `draft` at session start and `cancelRename` resets it so the next session starts clean. Rename e2e still passes the menu + double-click + Escape paths. Co-Authored-By: Claude Opus 4.7 --- components/edit/EditShell/EditShell.tsx | 7 +- components/edit/PlaybackChromeRoot.tsx | 2276 ++++++++++---------- components/edit/SlideNavRail/ThumbItem.tsx | 21 +- 3 files changed, 1150 insertions(+), 1154 deletions(-) diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx index 2e192c9be0..252569ef6c 100644 --- a/components/edit/EditShell/EditShell.tsx +++ b/components/edit/EditShell/EditShell.tsx @@ -194,12 +194,7 @@ function Frame({ title, leftRail, history, commands, trailing, children }: Frame animate={cmdAnimate} transition={{ ...stepTransition, delay: prefersReducedMotion ? 0 : COMMANDBAR_DELAY }} > - + } leftSlot={ diff --git a/components/edit/PlaybackChromeRoot.tsx b/components/edit/PlaybackChromeRoot.tsx index fa9d0cefac..8e23f978b5 100644 --- a/components/edit/PlaybackChromeRoot.tsx +++ b/components/edit/PlaybackChromeRoot.tsx @@ -71,618 +71,641 @@ interface PlaybackChromeRootProps { */ export const PlaybackChromeRoot = forwardRef( function PlaybackChromeRoot({ onRetryOutline, canEnterProMode, onEnterProMode }, ref) { - const { t } = useI18n(); - const { - mode, - getCurrentScene, - scenes, - currentSceneId, - setCurrentSceneId, - generatingOutlines, - outlines, - } = useStageStore(); - const failedOutlines = useStageStore.use.failedOutlines(); - - const currentScene = getCurrentScene(); - - // Layout state from settings store (persisted via localStorage) - const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed); - const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed); - const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth); - const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); - const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); - const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); - const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); - const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); - - // PlaybackEngine state - const [engineMode, setEngineMode] = useState('idle'); - const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle - const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture) - const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA) - const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1) - const [discussionTrigger, setDiscussionTrigger] = useState(null); - - // Speaking agent tracking (Issue 2) - const [speakingAgentId, setSpeakingAgentId] = useState(null); - - // Thinking state (Issue 5) - const [thinkingState, setThinkingState] = useState<{ - stage: string; - agentId?: string; - } | null>(null); - - // Cue user state (Issue 7) - const [isCueUser, setIsCueUser] = useState(false); - - // End flash state (Issue 3) - const [showEndFlash, setShowEndFlash] = useState(false); - const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion'); - - // Streaming state for stop button (Issue 1) - const [chatIsStreaming, setChatIsStreaming] = useState(false); - const [chatSessionType, setChatSessionType] = useState(null); - - // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input - const [isTopicPending, setIsTopicPending] = useState(false); - - // Active bubble ID for playback highlight in chat area (Issue 8) - const [activeBubbleId, setActiveBubbleId] = useState(null); - - // Scene switch confirmation dialog state - const [pendingSceneId, setPendingSceneId] = useState(null); - const [isPresenting, setIsPresenting] = useState(false); - const [controlsVisible, setControlsVisible] = useState(true); - const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false); - - // Whiteboard state (from canvas store so AI tools can open it) - const whiteboardOpen = useCanvasStore.use.whiteboardOpen(); - const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen(); - - // Selected agents from settings store (Zustand) - const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); - const ttsMuted = useSettingsStore((s) => s.ttsMuted); - const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); - - // Generate participants from selected agents - const participants = useMemo( - () => agentsToParticipants(selectedAgentIds, t), - [selectedAgentIds, t], - ); - - // Resolved AgentConfig array for hooks that need full agent objects - // Subscribe to the agents record so voiceConfig changes trigger re-resolution - const agentsRecord = useAgentRegistry((s) => s.agents); - const selectedAgents = useMemo( - () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null), - [agentsRecord, selectedAgentIds], - ); - - // Discussion TTS: audio indicator state - const [audioIndicatorState, setAudioIndicatorState] = useState('idle'); - const [audioAgentId, setAudioAgentId] = useState(null); - - const discussionTTS = useDiscussionTTS({ - enabled: ttsEnabled && !ttsMuted, - agents: selectedAgents, - onAudioStateChange: (agentId, state) => { - setAudioAgentId(agentId); - setAudioIndicatorState(state); - }, - }); - - // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback) - const pickStudentAgent = useCallback((): string => { - const registry = useAgentRegistry.getState(); - const agents = selectedAgentIds - .map((id) => registry.getAgent(id)) - .filter((a): a is AgentConfig => a != null); - const students = agents.filter((a) => a.role === 'student'); - if (students.length > 0) { - return students[Math.floor(Math.random() * students.length)].id; - } - const nonTeachers = agents.filter((a) => a.role !== 'teacher'); - if (nonTeachers.length > 0) { - return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id; - } - return agents[0]?.id || 'default-1'; - }, [selectedAgentIds]); - - const engineRef = useRef(null); - const audioPlayerRef = useRef(createAudioPlayer()); - const chatAreaRef = useRef(null); - const lectureSessionIdRef = useRef(null); - const lectureActionCounterRef = useRef(0); - const discussionAbortRef = useRef(null); - const presentationIdleTimerRef = useRef | null>(null); - const stageRef = useRef(null); - // Guard to prevent double flash when manual stop triggers onDiscussionEnd - const manualStopRef = useRef(false); - // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks - const sceneEpochRef = useRef(0); - // When true, the next engine init will auto-start playback (for auto-play scene advance) - const autoStartRef = useRef(false); - // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE) - const [isDiscussionPaused, setIsDiscussionPaused] = useState(false); - - /** - * Resume a soft-paused topic: re-call /chat with existing session messages. - * The director picks the next agent to continue. - */ - const doResumeTopic = useCallback(async () => { - // Clear old bubble immediately — no lingering on interrupted text - setIsTopicPending(false); - setLiveSpeech(null); - setSpeakingAgentId(null); - setThinkingState({ stage: 'director' }); - setChatIsStreaming(true); - // Transition engine back to live — onInputActivate paused it when soft-pausing, - // so we must explicitly resume to keep engine mode in sync with the chat loop. - engineRef.current?.resume(); - // Fire new chat round — SSE events will drive thinking → agent_start → speech - await chatAreaRef.current?.resumeActiveSession(); - }, []); - - /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */ - const resetLiveState = useCallback(() => { - setLiveSpeech(null); - setSpeakingAgentId(null); - setSpeechProgress(null); - setThinkingState(null); - setIsCueUser(false); - setIsTopicPending(false); - setChatIsStreaming(false); - setChatSessionType(null); - setIsDiscussionPaused(false); - }, []); - - /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */ - const resetSceneState = useCallback(() => { - resetLiveState(); - setPlaybackCompleted(false); - setLectureSpeech(null); - setSpeechProgress(null); - setShowEndFlash(false); - setActiveBubbleId(null); - setDiscussionTrigger(null); - }, [resetLiveState]); - - /** Request failure should exit live discussion UI without hard-closing the session. */ - const handleLiveSessionError = useCallback(() => { - engineRef.current?.handleDiscussionError(); - resetLiveState(); - setActiveBubbleId(null); - }, [resetLiveState]); - - /** - * Unified session cleanup — called by both roundtable stop button and chat area end button. - * Handles: engine transition, flash, roundtable state clearing. - */ - const doSessionCleanup = useCallback(() => { - const activeType = chatSessionType; - - // Engine cleanup — guard to avoid double flash from onDiscussionEnd - manualStopRef.current = true; - engineRef.current?.handleEndDiscussion(); - manualStopRef.current = false; - - // Show end flash with correct session type - if (activeType === 'qa' || activeType === 'discussion') { - setEndFlashSessionType(activeType); - setShowEndFlash(true); - setTimeout(() => setShowEndFlash(false), 1800); - } - - // Stop any in-flight discussion TTS audio - discussionTTS.cleanup(); - - resetLiveState(); - }, [chatSessionType, resetLiveState, discussionTTS]); - - // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) - const handleStopDiscussion = useCallback(async () => { - await chatAreaRef.current?.endActiveSession(); - doSessionCleanup(); - }, [doSessionCleanup]); - - // Imperative teardown so the parent can `await` SSE / engine / TTS - // shutdown before flipping mode to 'edit'. Mirrors what the old in- - // component `handleToggleEditMode` did, but exposed through ref so - // the toggle lives one layer up. - useImperativeHandle( - ref, - () => ({ - teardown: async () => { - await chatAreaRef.current?.endActiveSession(); - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - discussionAbortRef.current = null; - } - engineRef.current?.stop(); - discussionTTS.cleanup(); - resetSceneState(); + const { t } = useI18n(); + const { + mode, + getCurrentScene, + scenes, + currentSceneId, + setCurrentSceneId, + generatingOutlines, + outlines, + } = useStageStore(); + const failedOutlines = useStageStore.use.failedOutlines(); + + const currentScene = getCurrentScene(); + + // Layout state from settings store (persisted via localStorage) + const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed); + const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed); + const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth); + const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); + const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); + const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); + const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); + const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); + + // PlaybackEngine state + const [engineMode, setEngineMode] = useState('idle'); + const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle + const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture) + const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA) + const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1) + const [discussionTrigger, setDiscussionTrigger] = useState(null); + + // Speaking agent tracking (Issue 2) + const [speakingAgentId, setSpeakingAgentId] = useState(null); + + // Thinking state (Issue 5) + const [thinkingState, setThinkingState] = useState<{ + stage: string; + agentId?: string; + } | null>(null); + + // Cue user state (Issue 7) + const [isCueUser, setIsCueUser] = useState(false); + + // End flash state (Issue 3) + const [showEndFlash, setShowEndFlash] = useState(false); + const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>( + 'discussion', + ); + + // Streaming state for stop button (Issue 1) + const [chatIsStreaming, setChatIsStreaming] = useState(false); + const [chatSessionType, setChatSessionType] = useState(null); + + // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input + const [isTopicPending, setIsTopicPending] = useState(false); + + // Active bubble ID for playback highlight in chat area (Issue 8) + const [activeBubbleId, setActiveBubbleId] = useState(null); + + // Scene switch confirmation dialog state + const [pendingSceneId, setPendingSceneId] = useState(null); + const [isPresenting, setIsPresenting] = useState(false); + const [controlsVisible, setControlsVisible] = useState(true); + const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false); + + // Whiteboard state (from canvas store so AI tools can open it) + const whiteboardOpen = useCanvasStore.use.whiteboardOpen(); + const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen(); + + // Selected agents from settings store (Zustand) + const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); + const ttsMuted = useSettingsStore((s) => s.ttsMuted); + const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); + + // Generate participants from selected agents + const participants = useMemo( + () => agentsToParticipants(selectedAgentIds, t), + [selectedAgentIds, t], + ); + + // Resolved AgentConfig array for hooks that need full agent objects + // Subscribe to the agents record so voiceConfig changes trigger re-resolution + const agentsRecord = useAgentRegistry((s) => s.agents); + const selectedAgents = useMemo( + () => + selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null), + [agentsRecord, selectedAgentIds], + ); + + // Discussion TTS: audio indicator state + const [audioIndicatorState, setAudioIndicatorState] = useState('idle'); + const [audioAgentId, setAudioAgentId] = useState(null); + + const discussionTTS = useDiscussionTTS({ + enabled: ttsEnabled && !ttsMuted, + agents: selectedAgents, + onAudioStateChange: (agentId, state) => { + setAudioAgentId(agentId); + setAudioIndicatorState(state); }, - }), - [discussionTTS, resetSceneState], - ); - - const clearPresentationIdleTimer = useCallback(() => { - if (presentationIdleTimerRef.current) { - clearTimeout(presentationIdleTimerRef.current); - presentationIdleTimerRef.current = null; - } - }, []); - - const resetPresentationIdleTimer = useCallback(() => { - setControlsVisible(true); - clearPresentationIdleTimer(); - if (isPresenting && !isPresentationInteractionActive) { - presentationIdleTimerRef.current = setTimeout(() => { - setControlsVisible(false); - }, 3000); - } - }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]); - - const togglePresentation = useCallback(async () => { - const stageElement = stageRef.current; - if (!stageElement) return; - - try { - if (document.fullscreenElement === stageElement) { - // Unlock Escape key before exiting fullscreen - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (navigator as any).keyboard?.unlock?.(); - await document.exitFullscreen(); - return; + }); + + // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback) + const pickStudentAgent = useCallback((): string => { + const registry = useAgentRegistry.getState(); + const agents = selectedAgentIds + .map((id) => registry.getAgent(id)) + .filter((a): a is AgentConfig => a != null); + const students = agents.filter((a) => a.role === 'student'); + if (students.length > 0) { + return students[Math.floor(Math.random() * students.length)].id; + } + const nonTeachers = agents.filter((a) => a.role !== 'teacher'); + if (nonTeachers.length > 0) { + return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id; + } + return agents[0]?.id || 'default-1'; + }, [selectedAgentIds]); + + const engineRef = useRef(null); + const audioPlayerRef = useRef(createAudioPlayer()); + const chatAreaRef = useRef(null); + const lectureSessionIdRef = useRef(null); + const lectureActionCounterRef = useRef(0); + const discussionAbortRef = useRef(null); + const presentationIdleTimerRef = useRef | null>(null); + const stageRef = useRef(null); + // Guard to prevent double flash when manual stop triggers onDiscussionEnd + const manualStopRef = useRef(false); + // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks + const sceneEpochRef = useRef(0); + // When true, the next engine init will auto-start playback (for auto-play scene advance) + const autoStartRef = useRef(false); + // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE) + const [isDiscussionPaused, setIsDiscussionPaused] = useState(false); + + /** + * Resume a soft-paused topic: re-call /chat with existing session messages. + * The director picks the next agent to continue. + */ + const doResumeTopic = useCallback(async () => { + // Clear old bubble immediately — no lingering on interrupted text + setIsTopicPending(false); + setLiveSpeech(null); + setSpeakingAgentId(null); + setThinkingState({ stage: 'director' }); + setChatIsStreaming(true); + // Transition engine back to live — onInputActivate paused it when soft-pausing, + // so we must explicitly resume to keep engine mode in sync with the chat loop. + engineRef.current?.resume(); + // Fire new chat round — SSE events will drive thinking → agent_start → speech + await chatAreaRef.current?.resumeActiveSession(); + }, []); + + /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */ + const resetLiveState = useCallback(() => { + setLiveSpeech(null); + setSpeakingAgentId(null); + setSpeechProgress(null); + setThinkingState(null); + setIsCueUser(false); + setIsTopicPending(false); + setChatIsStreaming(false); + setChatSessionType(null); + setIsDiscussionPaused(false); + }, []); + + /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */ + const resetSceneState = useCallback(() => { + resetLiveState(); + setPlaybackCompleted(false); + setLectureSpeech(null); + setSpeechProgress(null); + setShowEndFlash(false); + setActiveBubbleId(null); + setDiscussionTrigger(null); + }, [resetLiveState]); + + /** Request failure should exit live discussion UI without hard-closing the session. */ + const handleLiveSessionError = useCallback(() => { + engineRef.current?.handleDiscussionError(); + resetLiveState(); + setActiveBubbleId(null); + }, [resetLiveState]); + + /** + * Unified session cleanup — called by both roundtable stop button and chat area end button. + * Handles: engine transition, flash, roundtable state clearing. + */ + const doSessionCleanup = useCallback(() => { + const activeType = chatSessionType; + + // Engine cleanup — guard to avoid double flash from onDiscussionEnd + manualStopRef.current = true; + engineRef.current?.handleEndDiscussion(); + manualStopRef.current = false; + + // Show end flash with correct session type + if (activeType === 'qa' || activeType === 'discussion') { + setEndFlashSessionType(activeType); + setShowEndFlash(true); + setTimeout(() => setShowEndFlash(false), 1800); + } + + // Stop any in-flight discussion TTS audio + discussionTTS.cleanup(); + + resetLiveState(); + }, [chatSessionType, resetLiveState, discussionTTS]); + + // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) + const handleStopDiscussion = useCallback(async () => { + await chatAreaRef.current?.endActiveSession(); + doSessionCleanup(); + }, [doSessionCleanup]); + + // Imperative teardown so the parent can `await` SSE / engine / TTS + // shutdown before flipping mode to 'edit'. Mirrors what the old in- + // component `handleToggleEditMode` did, but exposed through ref so + // the toggle lives one layer up. + useImperativeHandle( + ref, + () => ({ + teardown: async () => { + await chatAreaRef.current?.endActiveSession(); + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + discussionAbortRef.current = null; + } + engineRef.current?.stop(); + discussionTTS.cleanup(); + resetSceneState(); + }, + }), + [discussionTTS, resetSceneState], + ); + + const clearPresentationIdleTimer = useCallback(() => { + if (presentationIdleTimerRef.current) { + clearTimeout(presentationIdleTimerRef.current); + presentationIdleTimerRef.current = null; } + }, []); + const resetPresentationIdleTimer = useCallback(() => { setControlsVisible(true); - await stageElement.requestFullscreen(); - // Lock Escape key so it doesn't auto-exit fullscreen (#255) - // Escape is handled manually in our keydown handler instead - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); - setSidebarCollapsed(true); - setChatAreaCollapsed(true); - } catch { - // Firefox may deny fullscreen from certain keyboard events (e.g. F11) - console.warn('[Presentation] Fullscreen request denied — browser policy'); - } - }, [setChatAreaCollapsed, setSidebarCollapsed]); - - useEffect(() => { - const onFullscreenChange = () => { - const active = document.fullscreenElement === stageRef.current; - setIsPresenting(active); - - if (!active) { - // Ensure keyboard unlock on any fullscreen exit + clearPresentationIdleTimer(); + if (isPresenting && !isPresentationInteractionActive) { + presentationIdleTimerRef.current = setTimeout(() => { + setControlsVisible(false); + }, 3000); + } + }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]); + + const togglePresentation = useCallback(async () => { + const stageElement = stageRef.current; + if (!stageElement) return; + + try { + if (document.fullscreenElement === stageElement) { + // Unlock Escape key before exiting fullscreen + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).keyboard?.unlock?.(); + await document.exitFullscreen(); + return; + } + + setControlsVisible(true); + await stageElement.requestFullscreen(); + // Lock Escape key so it doesn't auto-exit fullscreen (#255) + // Escape is handled manually in our keydown handler instead // eslint-disable-next-line @typescript-eslint/no-explicit-any - (navigator as any).keyboard?.unlock?.(); + await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); + setSidebarCollapsed(true); + setChatAreaCollapsed(true); + } catch { + // Firefox may deny fullscreen from certain keyboard events (e.g. F11) + console.warn('[Presentation] Fullscreen request denied — browser policy'); + } + }, [setChatAreaCollapsed, setSidebarCollapsed]); + + useEffect(() => { + const onFullscreenChange = () => { + const active = document.fullscreenElement === stageRef.current; + setIsPresenting(active); + + if (!active) { + // Ensure keyboard unlock on any fullscreen exit + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).keyboard?.unlock?.(); + setControlsVisible(true); + clearPresentationIdleTimer(); + } + }; + + document.addEventListener('fullscreenchange', onFullscreenChange); + return () => document.removeEventListener('fullscreenchange', onFullscreenChange); + }, [clearPresentationIdleTimer]); + + useEffect(() => { + if (!isPresenting) { setControlsVisible(true); clearPresentationIdleTimer(); + return; } - }; - document.addEventListener('fullscreenchange', onFullscreenChange); - return () => document.removeEventListener('fullscreenchange', onFullscreenChange); - }, [clearPresentationIdleTimer]); + const handleActivity = () => { + resetPresentationIdleTimer(); + }; - useEffect(() => { - if (!isPresenting) { - setControlsVisible(true); - clearPresentationIdleTimer(); - return; - } + window.addEventListener('mousemove', handleActivity); + window.addEventListener('mousedown', handleActivity); + window.addEventListener('touchstart', handleActivity); + if (isPresentationInteractionActive) { + setControlsVisible(true); + clearPresentationIdleTimer(); + } else { + resetPresentationIdleTimer(); + } - const handleActivity = () => { - resetPresentationIdleTimer(); - }; + return () => { + window.removeEventListener('mousemove', handleActivity); + window.removeEventListener('mousedown', handleActivity); + window.removeEventListener('touchstart', handleActivity); + clearPresentationIdleTimer(); + }; + }, [ + clearPresentationIdleTimer, + isPresenting, + isPresentationInteractionActive, + resetPresentationIdleTimer, + ]); + + // Initialize playback engine when scene changes + useEffect(() => { + // Bump epoch so any stale SSE callbacks from the previous scene are discarded + sceneEpochRef.current++; + + // End any active QA/discussion session — this synchronously aborts the SSE + // stream inside use-chat-sessions (abortControllerRef.abort()), preventing + // stale onLiveSpeech callbacks from leaking into the new scene. + chatAreaRef.current?.endActiveSession(); + + // Also abort the engine-level discussion controller + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + discussionAbortRef.current = null; + } - window.addEventListener('mousemove', handleActivity); - window.addEventListener('mousedown', handleActivity); - window.addEventListener('touchstart', handleActivity); - if (isPresentationInteractionActive) { - setControlsVisible(true); - clearPresentationIdleTimer(); - } else { - resetPresentationIdleTimer(); - } - - return () => { - window.removeEventListener('mousemove', handleActivity); - window.removeEventListener('mousedown', handleActivity); - window.removeEventListener('touchstart', handleActivity); - clearPresentationIdleTimer(); - }; - }, [ - clearPresentationIdleTimer, - isPresenting, - isPresentationInteractionActive, - resetPresentationIdleTimer, - ]); - - // Initialize playback engine when scene changes - useEffect(() => { - // Bump epoch so any stale SSE callbacks from the previous scene are discarded - sceneEpochRef.current++; - - // End any active QA/discussion session — this synchronously aborts the SSE - // stream inside use-chat-sessions (abortControllerRef.abort()), preventing - // stale onLiveSpeech callbacks from leaking into the new scene. - chatAreaRef.current?.endActiveSession(); - - // Also abort the engine-level discussion controller - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - discussionAbortRef.current = null; - } - - // Stop any in-flight discussion TTS audio on scene switch - discussionTTS.cleanup(); - - // Reset all roundtable/live state so scenes are fully isolated - resetSceneState(); - - if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) { - engineRef.current = null; - setEngineMode('idle'); - - return; - } - - // Stop previous engine - if (engineRef.current) { - engineRef.current.stop(); - } - - // Get widget iframe messaging callback for interactive scenes (keyed by sceneId) - const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id); - - // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging) - const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current, widgetSendMessage); - - // Create new PlaybackEngine - const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, { - onModeChange: (mode) => { - setEngineMode(mode); - }, - onSceneChange: (_sceneId) => { - // Scene change handled by engine - }, - onSpeechStart: (text) => { - setLectureSpeech(text); - // Add to lecture session with incrementing index for dedup - // Chat area pacing is handled by the StreamBuffer (onTextReveal) - if (lectureSessionIdRef.current) { - const idx = lectureActionCounterRef.current++; - const speechId = `speech-${Date.now()}`; - chatAreaRef.current?.addLectureMessage( - lectureSessionIdRef.current, - { id: speechId, type: 'speech', text } as Action, - idx, - ); - // Track active bubble for highlight (Issue 8) - const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!); - if (msgId) setActiveBubbleId(msgId); - } - }, - onSpeechEnd: () => { - // Don't clear lectureSpeech — let it persist until the next - // onSpeechStart replaces it or the scene transitions. - // Clearing here causes fallback to idleText (first sentence). - setActiveBubbleId(null); - }, - onEffectFire: (effect: Effect) => { - // Add to lecture session with incrementing index - if ( - lectureSessionIdRef.current && - (effect.kind === 'spotlight' || effect.kind === 'laser') - ) { - const idx = lectureActionCounterRef.current++; - chatAreaRef.current?.addLectureMessage( - lectureSessionIdRef.current, - { - id: `${effect.kind}-${Date.now()}`, - type: effect.kind, - elementId: effect.targetId, - } as Action, - idx, - ); - } - }, - onProactiveShow: (trigger) => { - if (!trigger.agentId) { - // Mutate in-place so engine.currentTrigger also gets the agentId - // (confirmDiscussion reads agentId from the same object reference) - trigger.agentId = pickStudentAgent(); + // Stop any in-flight discussion TTS audio on scene switch + discussionTTS.cleanup(); + + // Reset all roundtable/live state so scenes are fully isolated + resetSceneState(); + + if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) { + engineRef.current = null; + setEngineMode('idle'); + + return; + } + + // Stop previous engine + if (engineRef.current) { + engineRef.current.stop(); + } + + // Get widget iframe messaging callback for interactive scenes (keyed by sceneId) + const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id); + + // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging) + const actionEngine = new ActionEngine( + useStageStore, + audioPlayerRef.current, + widgetSendMessage, + ); + + // Create new PlaybackEngine + const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, { + onModeChange: (mode) => { + setEngineMode(mode); + }, + onSceneChange: (_sceneId) => { + // Scene change handled by engine + }, + onSpeechStart: (text) => { + setLectureSpeech(text); + // Add to lecture session with incrementing index for dedup + // Chat area pacing is handled by the StreamBuffer (onTextReveal) + if (lectureSessionIdRef.current) { + const idx = lectureActionCounterRef.current++; + const speechId = `speech-${Date.now()}`; + chatAreaRef.current?.addLectureMessage( + lectureSessionIdRef.current, + { id: speechId, type: 'speech', text } as Action, + idx, + ); + // Track active bubble for highlight (Issue 8) + const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!); + if (msgId) setActiveBubbleId(msgId); + } + }, + onSpeechEnd: () => { + // Don't clear lectureSpeech — let it persist until the next + // onSpeechStart replaces it or the scene transitions. + // Clearing here causes fallback to idleText (first sentence). + setActiveBubbleId(null); + }, + onEffectFire: (effect: Effect) => { + // Add to lecture session with incrementing index + if ( + lectureSessionIdRef.current && + (effect.kind === 'spotlight' || effect.kind === 'laser') + ) { + const idx = lectureActionCounterRef.current++; + chatAreaRef.current?.addLectureMessage( + lectureSessionIdRef.current, + { + id: `${effect.kind}-${Date.now()}`, + type: effect.kind, + elementId: effect.targetId, + } as Action, + idx, + ); + } + }, + onProactiveShow: (trigger) => { + if (!trigger.agentId) { + // Mutate in-place so engine.currentTrigger also gets the agentId + // (confirmDiscussion reads agentId from the same object reference) + trigger.agentId = pickStudentAgent(); + } + setDiscussionTrigger(trigger); + }, + onProactiveHide: () => { + setDiscussionTrigger(null); + }, + onDiscussionConfirmed: (topic, prompt, agentId) => { + // Start SSE discussion via ChatArea + handleDiscussionSSE(topic, prompt, agentId); + }, + onDiscussionEnd: () => { + // Abort any active SSE + if (discussionAbortRef.current) { + discussionAbortRef.current.abort(); + discussionAbortRef.current = null; + } + setDiscussionTrigger(null); + // Stop any in-flight discussion TTS audio + discussionTTS.cleanup(); + // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup) + resetLiveState(); + // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup) + if (!manualStopRef.current) { + setEndFlashSessionType('discussion'); + setShowEndFlash(true); + setTimeout(() => setShowEndFlash(false), 1800); + } + // If all actions are exhausted (discussion was the last action), mark + // playback as completed so the bubble shows reset instead of play. + if (engineRef.current?.isExhausted()) { + setPlaybackCompleted(true); + } + }, + onUserInterrupt: (text) => { + // User interrupted → start a discussion via chat + chatAreaRef.current?.sendMessage(text); + }, + isAgentSelected: (agentId) => { + const ids = useSettingsStore.getState().selectedAgentIds; + return ids.includes(agentId); + }, + getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, + onComplete: () => { + // lectureSpeech intentionally NOT cleared — last sentence stays visible + // until scene transition (auto-play) or user restarts. Scene change + // effect handles the reset. + setPlaybackCompleted(true); + + // End lecture session on playback complete + if (lectureSessionIdRef.current) { + chatAreaRef.current?.endSession(lectureSessionIdRef.current); + lectureSessionIdRef.current = null; + } + // Auto-play: advance to next scene after a short pause + const { autoPlayLecture } = useSettingsStore.getState(); + if (autoPlayLecture) { + setTimeout(() => { + const stageState = useStageStore.getState(); + if (!useSettingsStore.getState().autoPlayLecture) return; + const allScenes = stageState.scenes; + const curId = stageState.currentSceneId; + const idx = allScenes.findIndex((s) => s.id === curId); + if (idx >= 0 && idx < allScenes.length - 1) { + const currentScene = allScenes[idx]; + if ( + currentScene.type === 'quiz' || + currentScene.type === 'interactive' || + currentScene.type === 'pbl' + ) { + return; + } + autoStartRef.current = true; + stageState.setCurrentSceneId(allScenes[idx + 1].id); + } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) { + // Last scene exhausted but next is still generating — go to pending page + const currentScene = allScenes[idx]; + if ( + currentScene.type === 'quiz' || + currentScene.type === 'interactive' || + currentScene.type === 'pbl' + ) { + return; + } + autoStartRef.current = true; + stageState.setCurrentSceneId(PENDING_SCENE_ID); + } + }, 1500); + } + }, + }); + + engineRef.current = engine; + + // Auto-start if triggered by auto-play scene advance + if (autoStartRef.current) { + autoStartRef.current = false; + (async () => { + if (currentScene && chatAreaRef.current) { + const sessionId = await chatAreaRef.current.startLecture(currentScene.id); + lectureSessionIdRef.current = sessionId; + lectureActionCounterRef.current = 0; + } + engine.start(); + })(); + } else { + // Load saved playback state and restore position (but never auto-play). + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs + }, [currentScene]); + + // Cleanup on unmount + useEffect(() => { + const audioPlayer = audioPlayerRef.current; + const chatArea = chatAreaRef.current; + return () => { + if (engineRef.current) { + engineRef.current.stop(); } - setDiscussionTrigger(trigger); - }, - onProactiveHide: () => { - setDiscussionTrigger(null); - }, - onDiscussionConfirmed: (topic, prompt, agentId) => { - // Start SSE discussion via ChatArea - handleDiscussionSSE(topic, prompt, agentId); - }, - onDiscussionEnd: () => { - // Abort any active SSE + audioPlayer.destroy(); if (discussionAbortRef.current) { discussionAbortRef.current.abort(); - discussionAbortRef.current = null; } - setDiscussionTrigger(null); - // Stop any in-flight discussion TTS audio discussionTTS.cleanup(); - // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup) - resetLiveState(); - // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup) - if (!manualStopRef.current) { - setEndFlashSessionType('discussion'); - setShowEndFlash(true); - setTimeout(() => setShowEndFlash(false), 1800); - } - // If all actions are exhausted (discussion was the last action), mark - // playback as completed so the bubble shows reset instead of play. - if (engineRef.current?.isExhausted()) { - setPlaybackCompleted(true); - } - }, - onUserInterrupt: (text) => { - // User interrupted → start a discussion via chat - chatAreaRef.current?.sendMessage(text); - }, - isAgentSelected: (agentId) => { - const ids = useSettingsStore.getState().selectedAgentIds; - return ids.includes(agentId); - }, - getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, - onComplete: () => { - // lectureSpeech intentionally NOT cleared — last sentence stays visible - // until scene transition (auto-play) or user restarts. Scene change - // effect handles the reset. - setPlaybackCompleted(true); - - // End lecture session on playback complete - if (lectureSessionIdRef.current) { - chatAreaRef.current?.endSession(lectureSessionIdRef.current); - lectureSessionIdRef.current = null; - } - // Auto-play: advance to next scene after a short pause - const { autoPlayLecture } = useSettingsStore.getState(); - if (autoPlayLecture) { - setTimeout(() => { - const stageState = useStageStore.getState(); - if (!useSettingsStore.getState().autoPlayLecture) return; - const allScenes = stageState.scenes; - const curId = stageState.currentSceneId; - const idx = allScenes.findIndex((s) => s.id === curId); - if (idx >= 0 && idx < allScenes.length - 1) { - const currentScene = allScenes[idx]; - if ( - currentScene.type === 'quiz' || - currentScene.type === 'interactive' || - currentScene.type === 'pbl' - ) { - return; - } - autoStartRef.current = true; - stageState.setCurrentSceneId(allScenes[idx + 1].id); - } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) { - // Last scene exhausted but next is still generating — go to pending page - const currentScene = allScenes[idx]; - if ( - currentScene.type === 'quiz' || - currentScene.type === 'interactive' || - currentScene.type === 'pbl' - ) { - return; - } - autoStartRef.current = true; - stageState.setCurrentSceneId(PENDING_SCENE_ID); - } - }, 1500); - } + chatArea?.endActiveSession(); + clearPresentationIdleTimer(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable + }, []); + + // Sync mute state from settings store to audioPlayer + useEffect(() => { + audioPlayerRef.current.setMuted(ttsMuted); + }, [ttsMuted]); + + // Sync volume from settings store to audioPlayer + const ttsVolume = useSettingsStore((s) => s.ttsVolume); + useEffect(() => { + if (!ttsMuted) { + audioPlayerRef.current.setVolume(ttsVolume); + } + }, [ttsVolume, ttsMuted]); + + // Sync playback speed to audio player (for live-updating current audio) + const playbackSpeed = useSettingsStore((s) => s.playbackSpeed); + useEffect(() => { + audioPlayerRef.current.setPlaybackRate(playbackSpeed); + }, [playbackSpeed]); + + /** + * Handle discussion SSE — POST /api/chat and push events to engine + */ + const handleDiscussionSSE = useCallback( + async (topic: string, prompt?: string, agentId?: string) => { + // Start discussion display in ChatArea (lecture speech is preserved independently) + chatAreaRef.current?.startDiscussion({ + topic, + prompt, + agentId: agentId || 'default-1', + }); + // Auto-switch to chat tab when discussion starts + chatAreaRef.current?.switchToTab('chat'); + // Immediately mark streaming for synchronized stop button + setChatIsStreaming(true); + setChatSessionType('discussion'); + // Optimistic thinking: show thinking dots immediately (same as onMessageSend) + setThinkingState({ stage: 'director' }); }, - }); + [], + ); - engineRef.current = engine; + // First speech text for idle display (extracted here for playbackView) + const firstSpeechText = useMemo( + () => + currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null, + [currentScene], + ); - // Auto-start if triggered by auto-play scene advance - if (autoStartRef.current) { - autoStartRef.current = false; - (async () => { - if (currentScene && chatAreaRef.current) { - const sessionId = await chatAreaRef.current.startLecture(currentScene.id); - lectureSessionIdRef.current = sessionId; - lectureActionCounterRef.current = 0; - } - engine.start(); - })(); - } else { - // Load saved playback state and restore position (but never auto-play). - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs - }, [currentScene]); - - // Cleanup on unmount - useEffect(() => { - const audioPlayer = audioPlayerRef.current; - const chatArea = chatAreaRef.current; - return () => { - if (engineRef.current) { - engineRef.current.stop(); - } - audioPlayer.destroy(); - if (discussionAbortRef.current) { - discussionAbortRef.current.abort(); - } - discussionTTS.cleanup(); - chatArea?.endActiveSession(); - clearPresentationIdleTimer(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable - }, []); - - // Sync mute state from settings store to audioPlayer - useEffect(() => { - audioPlayerRef.current.setMuted(ttsMuted); - }, [ttsMuted]); - - // Sync volume from settings store to audioPlayer - const ttsVolume = useSettingsStore((s) => s.ttsVolume); - useEffect(() => { - if (!ttsMuted) { - audioPlayerRef.current.setVolume(ttsVolume); - } - }, [ttsVolume, ttsMuted]); - - // Sync playback speed to audio player (for live-updating current audio) - const playbackSpeed = useSettingsStore((s) => s.playbackSpeed); - useEffect(() => { - audioPlayerRef.current.setPlaybackRate(playbackSpeed); - }, [playbackSpeed]); - - /** - * Handle discussion SSE — POST /api/chat and push events to engine - */ - const handleDiscussionSSE = useCallback( - async (topic: string, prompt?: string, agentId?: string) => { - // Start discussion display in ChatArea (lecture speech is preserved independently) - chatAreaRef.current?.startDiscussion({ - topic, - prompt, - agentId: agentId || 'default-1', - }); - // Auto-switch to chat tab when discussion starts - chatAreaRef.current?.switchToTab('chat'); - // Immediately mark streaming for synchronized stop button - setChatIsStreaming(true); - setChatSessionType('discussion'); - // Optimistic thinking: show thinking dots immediately (same as onMessageSend) - setThinkingState({ stage: 'director' }); - }, - [], - ); - - // First speech text for idle display (extracted here for playbackView) - const firstSpeechText = useMemo( - () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null, - [currentScene], - ); - - // Whether the speaking agent is a student (for bubble role derivation) - const speakingStudentFlag = useMemo(() => { - if (!speakingAgentId) return false; - const agent = useAgentRegistry.getState().getAgent(speakingAgentId); - return agent?.role !== 'teacher'; - }, [speakingAgentId]); - - // Centralised derived playback view - const playbackView = useMemo( - () => - computePlaybackView({ + // Whether the speaking agent is a student (for bubble role derivation) + const speakingStudentFlag = useMemo(() => { + if (!speakingAgentId) return false; + const agent = useAgentRegistry.getState().getAgent(speakingAgentId); + return agent?.role !== 'teacher'; + }, [speakingAgentId]); + + // Centralised derived playback view + const playbackView = useMemo( + () => + computePlaybackView({ + engineMode, + lectureSpeech, + liveSpeech, + speakingAgentId, + thinkingState, + isCueUser, + isTopicPending, + chatIsStreaming, + discussionTrigger, + playbackCompleted, + idleText: firstSpeechText, + speakingStudent: speakingStudentFlag, + sessionType: chatSessionType, + }), + [ engineMode, lectureSpeech, liveSpeech, @@ -693,517 +716,348 @@ export const PlaybackChromeRoot = forwardRef { - if (targetSceneId === currentSceneId) return false; - if (isTopicActive) { - setPendingSceneId(targetSceneId); - return false; - } - setCurrentSceneId(targetSceneId); - return true; - }, - [currentSceneId, isTopicActive, setCurrentSceneId], - ); - - /** User confirmed scene switch via AlertDialog */ - const confirmSceneSwitch = useCallback(() => { - if (!pendingSceneId) return; - chatAreaRef.current?.endActiveSession(); - doSessionCleanup(); - setCurrentSceneId(pendingSceneId); - setPendingSceneId(null); - }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]); - - /** User cancelled scene switch via AlertDialog */ - const cancelSceneSwitch = useCallback(() => { - setPendingSceneId(null); - }, []); - - // play/pause toggle - const handlePlayPause = useCallback(async () => { - const engine = engineRef.current; - if (!engine) return; - - const mode = engine.getMode(); - if (mode === 'playing' || mode === 'live') { - engine.pause(); - // Pause lecture buffer so text stops immediately - if (lectureSessionIdRef.current) { - chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current); - } - } else if (mode === 'paused') { - engine.resume(); - // Resume lecture buffer - if (lectureSessionIdRef.current) { - chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current); + firstSpeechText, + speakingStudentFlag, + chatSessionType, + ], + ); + + const isTopicActive = playbackView.isTopicActive; + + /** + * Gated scene switch — if a topic is active, show AlertDialog before switching. + * Returns true if the switch was immediate, false if gated (dialog shown). + */ + const gatedSceneSwitch = useCallback( + (targetSceneId: string): boolean => { + if (targetSceneId === currentSceneId) return false; + if (isTopicActive) { + setPendingSceneId(targetSceneId); + return false; + } + setCurrentSceneId(targetSceneId); + return true; + }, + [currentSceneId, isTopicActive, setCurrentSceneId], + ); + + /** User confirmed scene switch via AlertDialog */ + const confirmSceneSwitch = useCallback(() => { + if (!pendingSceneId) return; + chatAreaRef.current?.endActiveSession(); + doSessionCleanup(); + setCurrentSceneId(pendingSceneId); + setPendingSceneId(null); + }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]); + + /** User cancelled scene switch via AlertDialog */ + const cancelSceneSwitch = useCallback(() => { + setPendingSceneId(null); + }, []); + + // play/pause toggle + const handlePlayPause = useCallback(async () => { + const engine = engineRef.current; + if (!engine) return; + + const mode = engine.getMode(); + if (mode === 'playing' || mode === 'live') { + engine.pause(); + // Pause lecture buffer so text stops immediately + if (lectureSessionIdRef.current) { + chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current); + } + } else if (mode === 'paused') { + engine.resume(); + // Resume lecture buffer + if (lectureSessionIdRef.current) { + chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current); + } + } else { + const wasCompleted = playbackCompleted; + setPlaybackCompleted(false); + // Starting playback - create/reuse lecture session + if (currentScene && chatAreaRef.current) { + const sessionId = await chatAreaRef.current.startLecture(currentScene.id); + lectureSessionIdRef.current = sessionId; + } + if (wasCompleted) { + // Restart from beginning (user clicked restart after completion) + lectureActionCounterRef.current = 0; + engine.start(); + } else { + // Continue from current position (e.g. after discussion end) + engine.continuePlayback(); + } } - } else { - const wasCompleted = playbackCompleted; - setPlaybackCompleted(false); - // Starting playback - create/reuse lecture session - if (currentScene && chatAreaRef.current) { - const sessionId = await chatAreaRef.current.startLecture(currentScene.id); - lectureSessionIdRef.current = sessionId; + }, [playbackCompleted, currentScene]); + + // get scene information + const isPendingScene = currentSceneId === PENDING_SCENE_ID; + const hasNextPending = generatingOutlines.length > 0; + // True when every outline has materialized into a scene and nothing is + // currently generating — signals the classroom has finished and the user + // can see a completion page. Comparing scenes.length === outlines.length + // (rather than just `scenes.length > 0`) means a partial generation with + // some failed outlines does not falsely trigger completion. + const isCourseComplete = + outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0; + const canAdvanceToPendingSlot = hasNextPending || isCourseComplete; + + // previous scene (gated) + const handlePreviousScene = useCallback(() => { + if (isPendingScene) { + // From pending page → go to last real scene + if (scenes.length > 0) { + gatedSceneSwitch(scenes[scenes.length - 1].id); + } + return; } - if (wasCompleted) { - // Restart from beginning (user clicked restart after completion) - lectureActionCounterRef.current = 0; - engine.start(); - } else { - // Continue from current position (e.g. after discussion end) - engine.continuePlayback(); + const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); + if (currentIndex > 0) { + gatedSceneSwitch(scenes[currentIndex - 1].id); } - } - }, [playbackCompleted, currentScene]); - - // get scene information - const isPendingScene = currentSceneId === PENDING_SCENE_ID; - const hasNextPending = generatingOutlines.length > 0; - // True when every outline has materialized into a scene and nothing is - // currently generating — signals the classroom has finished and the user - // can see a completion page. Comparing scenes.length === outlines.length - // (rather than just `scenes.length > 0`) means a partial generation with - // some failed outlines does not falsely trigger completion. - const isCourseComplete = - outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0; - const canAdvanceToPendingSlot = hasNextPending || isCourseComplete; - - // previous scene (gated) - const handlePreviousScene = useCallback(() => { - if (isPendingScene) { - // From pending page → go to last real scene - if (scenes.length > 0) { - gatedSceneSwitch(scenes[scenes.length - 1].id); + }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]); + + // next scene (gated) + const handleNextScene = useCallback(() => { + if (isPendingScene) return; // Already on pending, nowhere to go + const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); + if (currentIndex < scenes.length - 1) { + gatedSceneSwitch(scenes[currentIndex + 1].id); + } else if (canAdvanceToPendingSlot) { + // On last real scene → advance to pending slot (generating or completion page) + setCurrentSceneId(PENDING_SCENE_ID); } - return; - } - const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); - if (currentIndex > 0) { - gatedSceneSwitch(scenes[currentIndex - 1].id); - } - }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]); - - // next scene (gated) - const handleNextScene = useCallback(() => { - if (isPendingScene) return; // Already on pending, nowhere to go - const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); - if (currentIndex < scenes.length - 1) { - gatedSceneSwitch(scenes[currentIndex + 1].id); - } else if (canAdvanceToPendingSlot) { - // On last real scene → advance to pending slot (generating or completion page) - setCurrentSceneId(PENDING_SCENE_ID); - } - }, [ - currentSceneId, - gatedSceneSwitch, - canAdvanceToPendingSlot, - isPendingScene, - scenes, - setCurrentSceneId, - ]); - - const currentSceneIndex = isPendingScene - ? scenes.length - : scenes.findIndex((s) => s.id === currentSceneId); - const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0); - - // get action information - const totalActions = currentScene?.actions?.length || 0; - - // whiteboard toggle - const handleWhiteboardToggle = () => { - setWhiteboardOpen(!whiteboardOpen); - }; - - const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => { - if (!(target instanceof HTMLElement)) return false; - - if (target.isContentEditable || target.closest('[contenteditable="true"]')) { - return true; - } + }, [ + currentSceneId, + gatedSceneSwitch, + canAdvanceToPendingSlot, + isPendingScene, + scenes, + setCurrentSceneId, + ]); + + const currentSceneIndex = isPendingScene + ? scenes.length + : scenes.findIndex((s) => s.id === currentSceneId); + const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0); + + // get action information + const totalActions = currentScene?.actions?.length || 0; + + // whiteboard toggle + const handleWhiteboardToggle = () => { + setWhiteboardOpen(!whiteboardOpen); + }; - return ( - target.closest( - ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '), - ) !== null - ); - }, []); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser - if (event.ctrlKey || event.metaKey || event.altKey) return; - if ( - isPresentationShortcutTarget(event.target) || - isPresentationShortcutTarget(document.activeElement) - ) { - return; + const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + + if (target.isContentEditable || target.closest('[contenteditable="true"]')) { + return true; } - switch (event.key) { - case 'ArrowLeft': - if (!isPresenting) return; - event.preventDefault(); - handlePreviousScene(); - resetPresentationIdleTimer(); - break; - case 'ArrowRight': - if (!isPresenting) return; - event.preventDefault(); - handleNextScene(); - resetPresentationIdleTimer(); - break; - case ' ': - case 'Spacebar': - // During active QA/discussion, Roundtable owns Space for - // buffer-level pause/resume — don't also fire engine play/pause. - if (chatSessionType === 'qa' || chatSessionType === 'discussion') break; - event.preventDefault(); - handlePlayPause(); - break; - case 'Escape': - // With keyboard.lock(), Escape no longer auto-exits fullscreen. - // If panels are open, roundtable handles Escape (close panels). - // If no panels are open, manually exit fullscreen. - if (isPresenting && !isPresentationInteractionActive) { + return ( + target.closest( + ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '), + ) !== null + ); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser + if (event.ctrlKey || event.metaKey || event.altKey) return; + if ( + isPresentationShortcutTarget(event.target) || + isPresentationShortcutTarget(document.activeElement) + ) { + return; + } + + switch (event.key) { + case 'ArrowLeft': + if (!isPresenting) return; event.preventDefault(); - togglePresentation(); - } - break; - case 'ArrowUp': - event.preventDefault(); - setTTSVolume(ttsVolume + 0.1); - break; - case 'ArrowDown': - event.preventDefault(); - setTTSVolume(ttsVolume - 0.1); - break; - case 'm': - case 'M': - event.preventDefault(); - setTTSMuted(!ttsMuted); - break; - case 's': - case 'S': - event.preventDefault(); - setSidebarCollapsed(!sidebarCollapsed); - break; - case 'c': - case 'C': + handlePreviousScene(); + resetPresentationIdleTimer(); + break; + case 'ArrowRight': + if (!isPresenting) return; + event.preventDefault(); + handleNextScene(); + resetPresentationIdleTimer(); + break; + case ' ': + case 'Spacebar': + // During active QA/discussion, Roundtable owns Space for + // buffer-level pause/resume — don't also fire engine play/pause. + if (chatSessionType === 'qa' || chatSessionType === 'discussion') break; + event.preventDefault(); + handlePlayPause(); + break; + case 'Escape': + // With keyboard.lock(), Escape no longer auto-exits fullscreen. + // If panels are open, roundtable handles Escape (close panels). + // If no panels are open, manually exit fullscreen. + if (isPresenting && !isPresentationInteractionActive) { + event.preventDefault(); + togglePresentation(); + } + break; + case 'ArrowUp': + event.preventDefault(); + setTTSVolume(ttsVolume + 0.1); + break; + case 'ArrowDown': + event.preventDefault(); + setTTSVolume(ttsVolume - 0.1); + break; + case 'm': + case 'M': + event.preventDefault(); + setTTSMuted(!ttsMuted); + break; + case 's': + case 'S': + event.preventDefault(); + setSidebarCollapsed(!sidebarCollapsed); + break; + case 'c': + case 'C': + event.preventDefault(); + setChatAreaCollapsed(!chatAreaCollapsed); + break; + default: + break; + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [ + chatSessionType, + chatAreaCollapsed, + handleNextScene, + handlePlayPause, + handlePreviousScene, + isPresenting, + isPresentationInteractionActive, + isPresentationShortcutTarget, + resetPresentationIdleTimer, + setChatAreaCollapsed, + setSidebarCollapsed, + setTTSMuted, + setTTSVolume, + sidebarCollapsed, + togglePresentation, + ttsMuted, + ttsVolume, + ]); + + // Intercept F11 to use our presentation fullscreen instead of browser fullscreen + // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit) + useEffect(() => { + const onF11 = (event: KeyboardEvent) => { + if (event.key === 'F11') { event.preventDefault(); - setChatAreaCollapsed(!chatAreaCollapsed); - break; + togglePresentation(); + } + }; + + window.addEventListener('keydown', onF11); + return () => window.removeEventListener('keydown', onF11); + }, [togglePresentation]); + + // Map engine mode to the CanvasArea's expected engine state + const canvasEngineState = (() => { + switch (engineMode) { + case 'playing': + case 'live': + return 'playing'; + case 'paused': + return 'paused'; default: - break; + return 'idle'; } - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [ - chatSessionType, - chatAreaCollapsed, - handleNextScene, - handlePlayPause, - handlePreviousScene, - isPresenting, - isPresentationInteractionActive, - isPresentationShortcutTarget, - resetPresentationIdleTimer, - setChatAreaCollapsed, - setSidebarCollapsed, - setTTSMuted, - setTTSVolume, - sidebarCollapsed, - togglePresentation, - ttsMuted, - ttsVolume, - ]); - - // Intercept F11 to use our presentation fullscreen instead of browser fullscreen - // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit) - useEffect(() => { - const onF11 = (event: KeyboardEvent) => { - if (event.key === 'F11') { - event.preventDefault(); - togglePresentation(); - } - }; + })(); + + // Build discussion request for Roundtable ProactiveCard from trigger + const discussionRequest: DiscussionAction | null = discussionTrigger + ? { + type: 'discussion', + id: discussionTrigger.id, + topic: discussionTrigger.question, + prompt: discussionTrigger.prompt, + agentId: discussionTrigger.agentId || 'default-1', + } + : null; + + // Scene viewer height — header is 80px when visible, roundtable is + // 192px in playback mode (autonomous hides it). Mode is guaranteed + // non-'edit' here since the parent Stage unmounts this component + // when entering Pro mode. + const sceneViewerHeight = (() => { + const headerHeight = isPresenting ? 0 : 80; + const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0; + return `calc(100% - ${headerHeight + roundtableHeight}px)`; + })(); - window.addEventListener('keydown', onF11); - return () => window.removeEventListener('keydown', onF11); - }, [togglePresentation]); - - // Map engine mode to the CanvasArea's expected engine state - const canvasEngineState = (() => { - switch (engineMode) { - case 'playing': - case 'live': - return 'playing'; - case 'paused': - return 'paused'; - default: - return 'idle'; - } - })(); - - // Build discussion request for Roundtable ProactiveCard from trigger - const discussionRequest: DiscussionAction | null = discussionTrigger - ? { - type: 'discussion', - id: discussionTrigger.id, - topic: discussionTrigger.question, - prompt: discussionTrigger.prompt, - agentId: discussionTrigger.agentId || 'default-1', - } - : null; - - // Scene viewer height — header is 80px when visible, roundtable is - // 192px in playback mode (autonomous hides it). Mode is guaranteed - // non-'edit' here since the parent Stage unmounts this component - // when entering Pro mode. - const sceneViewerHeight = (() => { - const headerHeight = isPresenting ? 0 : 80; - const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0; - return `calc(100% - ${headerHeight + roundtableHeight}px)`; - })(); - - return ( -
- - - {/* Main Content Area */} -
- {/* Header — playback only. The Pro Switch fires `onEnterProMode` + return ( +
+ + + {/* Main Content Area */} +
+ {/* Header — playback only. The Pro Switch fires `onEnterProMode` (passed by the parent Stage) which acquires the cross-tab edit lock and then awaits our `teardown()` before flipping mode to 'edit'. */} - {!isPresenting && ( -
- )} + {!isPresenting && ( +
+ )} - {/* Canvas Area — playback-only renderer. The parent Stage swaps + {/* Canvas Area — playback-only renderer. The parent Stage swaps this whole PlaybackChromeRoot out when entering edit mode, so no inline branching is needed here. */} -
- setSidebarCollapsed(!sidebarCollapsed)} - onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} - onPrevSlide={handlePreviousScene} - onNextSlide={handleNextScene} - onPlayPause={handlePlayPause} - onWhiteboardClose={handleWhiteboardToggle} - isPresenting={isPresenting} - onTogglePresentation={togglePresentation} - showStopDiscussion={ - engineMode === 'live' || - (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) - } - onStopDiscussion={handleStopDiscussion} - hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)} - isPendingScene={isPendingScene} - isCourseComplete={isCourseComplete} - isGenerationFailed={ - isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id) - } - onRetryGeneration={ - onRetryOutline && generatingOutlines[0] - ? () => onRetryOutline(generatingOutlines[0].id) - : undefined - } - /> -
- - {/* Roundtable Area */} - {mode === 'playback' && (
- { - // Always clear Level-1 pause state — the closure may hold a stale - // isDiscussionPaused value (e.g. voice input's onTranscription callback - // captures onMessageSend before React re-renders with the updated state). - setIsDiscussionPaused(false); - // Clear the sticky livePausedRef so the next agent-loop buffer - // starts unpaused. (pauseActiveLiveBuffer sets a ref that new - // buffers inherit — must be cleared before sendMessage creates one.) - chatAreaRef.current?.resumeActiveLiveBuffer(); - // Flush any buffered / in-flight TTS audio from the previous - // agent turn so it doesn't leak into the next round. - discussionTTS.cleanup(); - // Clear soft-paused state — user is continuing the topic - if (isTopicPending) { - setIsTopicPending(false); - setLiveSpeech(null); - setSpeakingAgentId(null); - } - // User interrupts during playback — handleUserInterrupt triggers - // onUserInterrupt callback which already calls sendMessage, so skip - // the direct sendMessage below to avoid sending twice. - // Include 'paused' because onInputActivate pauses the engine before - // the user finishes typing — without this the interrupt position - // would never be saved and resuming after QA skips to the next sentence. - if ( - engineRef.current && - (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused') - ) { - engineRef.current.handleUserInterrupt(msg); - } else { - chatAreaRef.current?.sendMessage(msg); - } - // Auto-switch to chat tab when user sends a message - chatAreaRef.current?.switchToTab('chat'); - setIsCueUser(false); - // Immediately mark streaming for synchronized stop button - setChatIsStreaming(true); - setChatSessionType(chatSessionType || 'qa'); - // Optimistic thinking: show thinking dots immediately so there's - // no blank gap between userMessage expiry and the SSE thinking event. - // The real SSE event will overwrite this with the same or updated value. - setThinkingState({ stage: 'director' }); - }} - onDiscussionStart={() => { - // User clicks "Join" on ProactiveCard - engineRef.current?.confirmDiscussion(); - }} - onDiscussionSkip={() => { - // User clicks "Skip" on ProactiveCard - engineRef.current?.skipDiscussion(); - }} - onStopDiscussion={handleStopDiscussion} - onInputActivate={() => { - // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering. - // User resumes manually via Space / pause button after closing the input. - // No isDiscussionPaused guard — always attempt to pause the buffer. - // The return value ensures UI state stays in sync with buffer state. - if (chatSessionType === 'qa' || chatSessionType === 'discussion') { - const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); - if (paused) { - discussionTTS.pause(); - setIsDiscussionPaused(true); - } - } - // Also pause playback engine - if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { - engineRef.current.pause(); - } - }} - onResumeTopic={doResumeTopic} - onPlayPause={handlePlayPause} - isDiscussionPaused={isDiscussionPaused} - onDiscussionPause={() => { - const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); - if (paused) { - discussionTTS.pause(); - setIsDiscussionPaused(true); - } - }} - onDiscussionResume={() => { - chatAreaRef.current?.resumeActiveLiveBuffer(); - discussionTTS.resume(); - setIsDiscussionPaused(false); - }} - totalActions={totalActions} - currentActionIndex={0} + setChatAreaCollapsed(!chatAreaCollapsed)} onPrevSlide={handlePreviousScene} onNextSlide={handleNextScene} + onPlayPause={handlePlayPause} onWhiteboardClose={handleWhiteboardToggle} isPresenting={isPresenting} - controlsVisible={controlsVisible} onTogglePresentation={togglePresentation} - onPresentationInteractionChange={setIsPresentationInteractionActive} - fullscreenContainerRef={stageRef} + showStopDiscussion={ + engineMode === 'live' || + (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) + } + onStopDiscussion={handleStopDiscussion} + hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)} + isPendingScene={isPendingScene} + isCourseComplete={isCourseComplete} + isGenerationFailed={ + isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id) + } + onRetryGeneration={ + onRetryOutline && generatingOutlines[0] + ? () => onRetryOutline(generatingOutlines[0].id) + : undefined + } />
- )} -
- {/* Chat Area — playback / autonomous always renders it here; Pro + {/* Roundtable Area */} + {mode === 'playback' && ( +
+ { + // Always clear Level-1 pause state — the closure may hold a stale + // isDiscussionPaused value (e.g. voice input's onTranscription callback + // captures onMessageSend before React re-renders with the updated state). + setIsDiscussionPaused(false); + // Clear the sticky livePausedRef so the next agent-loop buffer + // starts unpaused. (pauseActiveLiveBuffer sets a ref that new + // buffers inherit — must be cleared before sendMessage creates one.) + chatAreaRef.current?.resumeActiveLiveBuffer(); + // Flush any buffered / in-flight TTS audio from the previous + // agent turn so it doesn't leak into the next round. + discussionTTS.cleanup(); + // Clear soft-paused state — user is continuing the topic + if (isTopicPending) { + setIsTopicPending(false); + setLiveSpeech(null); + setSpeakingAgentId(null); + } + // User interrupts during playback — handleUserInterrupt triggers + // onUserInterrupt callback which already calls sendMessage, so skip + // the direct sendMessage below to avoid sending twice. + // Include 'paused' because onInputActivate pauses the engine before + // the user finishes typing — without this the interrupt position + // would never be saved and resuming after QA skips to the next sentence. + if ( + engineRef.current && + (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused') + ) { + engineRef.current.handleUserInterrupt(msg); + } else { + chatAreaRef.current?.sendMessage(msg); + } + // Auto-switch to chat tab when user sends a message + chatAreaRef.current?.switchToTab('chat'); + setIsCueUser(false); + // Immediately mark streaming for synchronized stop button + setChatIsStreaming(true); + setChatSessionType(chatSessionType || 'qa'); + // Optimistic thinking: show thinking dots immediately so there's + // no blank gap between userMessage expiry and the SSE thinking event. + // The real SSE event will overwrite this with the same or updated value. + setThinkingState({ stage: 'director' }); + }} + onDiscussionStart={() => { + // User clicks "Join" on ProactiveCard + engineRef.current?.confirmDiscussion(); + }} + onDiscussionSkip={() => { + // User clicks "Skip" on ProactiveCard + engineRef.current?.skipDiscussion(); + }} + onStopDiscussion={handleStopDiscussion} + onInputActivate={() => { + // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering. + // User resumes manually via Space / pause button after closing the input. + // No isDiscussionPaused guard — always attempt to pause the buffer. + // The return value ensures UI state stays in sync with buffer state. + if (chatSessionType === 'qa' || chatSessionType === 'discussion') { + const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); + if (paused) { + discussionTTS.pause(); + setIsDiscussionPaused(true); + } + } + // Also pause playback engine + if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { + engineRef.current.pause(); + } + }} + onResumeTopic={doResumeTopic} + onPlayPause={handlePlayPause} + isDiscussionPaused={isDiscussionPaused} + onDiscussionPause={() => { + const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); + if (paused) { + discussionTTS.pause(); + setIsDiscussionPaused(true); + } + }} + onDiscussionResume={() => { + chatAreaRef.current?.resumeActiveLiveBuffer(); + discussionTTS.resume(); + setIsDiscussionPaused(false); + }} + totalActions={totalActions} + currentActionIndex={0} + currentSceneIndex={currentSceneIndex} + scenesCount={totalScenesCount} + whiteboardOpen={whiteboardOpen} + sidebarCollapsed={sidebarCollapsed} + chatCollapsed={chatAreaCollapsed} + onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} + onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} + onPrevSlide={handlePreviousScene} + onNextSlide={handleNextScene} + onWhiteboardClose={handleWhiteboardToggle} + isPresenting={isPresenting} + controlsVisible={controlsVisible} + onTogglePresentation={togglePresentation} + onPresentationInteractionChange={setIsPresentationInteractionActive} + fullscreenContainerRef={stageRef} + /> +
+ )} +
+ + {/* Chat Area — playback / autonomous always renders it here; Pro (edit) mode unmounts this whole PlaybackChromeRoot, so the edit branch has no chat. */} -
- + -
+
- {/* Scene switch confirmation dialog */} - { - if (!open) cancelSceneSwitch(); - }} - > - { + if (!open) cancelSceneSwitch(); + }} > - - {t('stage.confirmSwitchTitle')} - - {/* Top accent bar */} -
- -
- {/* Icon */} -
- + + + {t('stage.confirmSwitchTitle')} + + {/* Top accent bar */} +
+ +
+ {/* Icon */} +
+ +
+ {/* Title */} +

+ {t('stage.confirmSwitchTitle')} +

+ {/* Description */} +

+ {t('stage.confirmSwitchMessage')} +

- {/* Title */} -

- {t('stage.confirmSwitchTitle')} -

- {/* Description */} -

- {t('stage.confirmSwitchMessage')} -

-
- - - {t('common.cancel')} - - - {t('common.confirm')} - - -
- -
- ); + + + {t('common.cancel')} + + + {t('common.confirm')} + + + + +
+ ); }, ); diff --git a/components/edit/SlideNavRail/ThumbItem.tsx b/components/edit/SlideNavRail/ThumbItem.tsx index 59338d06ba..56cbf33f98 100644 --- a/components/edit/SlideNavRail/ThumbItem.tsx +++ b/components/edit/SlideNavRail/ThumbItem.tsx @@ -42,17 +42,14 @@ function ThumbItemComponent({ const ref = useRef(null); const visible = useNearViewport(ref); - // Inline title-edit state. + // Inline title-edit state. `draft` is only used while renaming; when + // idle we derive the visible title from `scene.title` directly so an + // external rename (other tab / Duplicate suffix) shows up without a + // sync effect. `startRename` seeds `draft` once at session start. const [renaming, setRenaming] = useState(false); const [draft, setDraft] = useState(scene.title); const inputRef = useRef(null); - // Reset draft whenever scene title changes externally (e.g. another tab - // edited it, or duplicate appended a suffix). - useEffect(() => { - if (!renaming) setDraft(scene.title); - }, [scene.title, renaming]); - const startRename = useCallback(() => { setDraft(scene.title); setRenaming(true); @@ -70,13 +67,13 @@ function ThumbItemComponent({ const trimmed = draft.trim(); if (trimmed && trimmed !== scene.title) { updateScene(scene.id, { title: trimmed }); - } else if (!trimmed) { - setDraft(scene.title); } setRenaming(false); }, [draft, scene.id, scene.title, updateScene]); const cancelRename = useCallback(() => { + // Reset draft to the canonical title so the next rename session + // starts from a clean state. setDraft(scene.title); setRenaming(false); }, [scene.title]); @@ -217,11 +214,7 @@ function ThumbItemComponent({ {t('edit.nav.duplicate')} - + {t('edit.nav.delete')} From a2b297c9b7db43a56b06741527211bfd9bf13c95 Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 09:55:15 -0400 Subject: [PATCH 12/14] =?UTF-8?q?fix(maic-editor):=20CR-loop=20pass=20?= =?UTF-8?q?=E2=80=94=20pointer=20capture,=20stage-scoped=20recycle,=20equa?= =?UTF-8?q?lity=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #601 reviewer feedback. **Drag handle uses Pointer Events with `setPointerCapture`** so the rail no longer gets stuck in "still dragging" state when the cursor leaves the window, the OS reclaims focus, or a tab interrupt suppresses the mouseup that the old document-bound mousemove/mouseup pair relied on. The handle's onPointerMove/Up/Cancel are now bound directly on the element; capture guarantees event delivery for the lifetime of the gesture. Drag tracking e2e still PASS (1 px cursor lock). **Toast Undo guards stage identity** before re-inserting the deleted scene. If the user navigated to a different stage while the toast was up, the recycle entry belongs to the previous stage and `insertSceneAfter` would reject it on stage-id mismatch — silently losing the deleted scene. New check drops the undo cleanly when stage ids don't match. The `stageId` field was already captured on RecycleEntry; just wasn't consulted. **`surfaceStateEqual` extended** to compare per-item `id` / `disabled` / `label` on `floatingActions` (was length-only) and per-item `id`/`severity`/`message` on `hints` (was length-only). Today's slide surface returns `floatingActions: []` and `hints: []` so this is dormant, but PR3b's z-order actions land in `floatingActions` — pinning the equality semantics now keeps a future state field from silently going stale in the chrome. SurfaceState gets a maintenance note cross-linking to the equality function. **Header.tsx mode guard comment** updated. The `mode !== 'edit'` guard around the title block isn't dead — it covers the ~280ms AnimatePresence exit window where playback chrome is still rendering its exit animation while mode has flipped to 'edit'. Without the guard, this title would briefly stack on top of the incoming EditChromeRoot's CommandBar title during the cross-fade. Co-Authored-By: Claude Opus 4.7 --- components/edit/EditShell/EditShell.tsx | 35 +++++- components/edit/SlideNavRail/SlideNavRail.tsx | 119 +++++++++++++----- components/header.tsx | 13 +- lib/edit/scene-editor-surface.ts | 8 ++ 4 files changed, 134 insertions(+), 41 deletions(-) diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx index 252569ef6c..442de49b57 100644 --- a/components/edit/EditShell/EditShell.tsx +++ b/components/edit/EditShell/EditShell.tsx @@ -135,10 +135,23 @@ function SurfaceStateRunner({ /** * Field-by-field equality for the subset of SurfaceState that the chrome - * reads (CommandBar history/insertItems/commands + Frame.hasSelection + - * floating/hints). Reference-equal `content` is the canonical signal that - * the slide buffer hasn't changed; insert/command arrays are compared by - * length + per-item `active` flag (which is what CommandBar styles on). + * reads. Surface hooks (e.g. `useSlideSurfaceState`) return a fresh object + * literal every render, so naive reference equality would publish on + * every render and trip an infinite render loop via the runner → + * setState → re-render cycle. We compare semantic content instead. + * + * **When you extend `SurfaceState`** (new field on `EditorCommand`, + * `InsertPaletteItem`, `FloatingAction`, `EditorHint`, or a new top-level + * field), update this function in lock-step. A field that's read by the + * chrome but missing from the comparison silently goes stale. + * + * - `content` is reference-equal as long as the in-memory slide buffer + * hasn't been committed — the canonical "real change" signal. + * - History flags compared individually (functions like undo/redo are + * stable references via `useSlideEditSession.getState()`). + * - InsertPaletteItem / EditorCommand / FloatingAction arrays: length + + * per-item `id` + per-item flags that drive visual state. + * - EditorHint compared by length + per-item severity/message. */ function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean { if (!b) return false; @@ -158,7 +171,19 @@ function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean { if (a.commands[i].disabled !== b.commands[i].disabled) return false; } if (a.floatingActions.length !== b.floatingActions.length) return false; - if ((a.hints?.length ?? 0) !== (b.hints?.length ?? 0)) return false; + for (let i = 0; i < a.floatingActions.length; i++) { + if (a.floatingActions[i].id !== b.floatingActions[i].id) return false; + if (a.floatingActions[i].disabled !== b.floatingActions[i].disabled) return false; + if (a.floatingActions[i].label !== b.floatingActions[i].label) return false; + } + const aHints = a.hints ?? []; + const bHints = b.hints ?? []; + if (aHints.length !== bHints.length) return false; + for (let i = 0; i < aHints.length; i++) { + if (aHints[i].id !== bHints[i].id) return false; + if (aHints[i].severity !== bHints[i].severity) return false; + if (aHints[i].message !== bHints[i].message) return false; + } return true; } diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx index 8ad5617744..eca4315876 100644 --- a/components/edit/SlideNavRail/SlideNavRail.tsx +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -55,55 +55,95 @@ export function SlideNavRail() { // // We mutate the rail's `style.width` directly on the DOM during pointer // move (bypassing React entirely) and only commit the final width to the - // settings store on mouse-up. This is what makes the handle feel glued + // settings store on pointer-up. This is what makes the handle feel glued // to the cursor: there's no React render → reconcile → DOM commit - // latency between mousemove and the visible width change. The thumbnails - // inside (which depend on the rail's CSS width via ResizeObserver in - // `ThumbnailSlide`) get notified by the browser's layout engine on the - // same frame, so they scale in lock-step. + // latency between move events and the visible width change. + // + // Pointer Events (with `setPointerCapture` on the handle) replace the + // older `document` mousemove/mouseup binding. With capture, the handle + // receives `pointerup` / `pointercancel` even if the cursor leaves the + // window, the OS reclaims focus, or a tab switch interrupts the gesture + // — none of which fire `document` mouseup, which previously left the + // rail stuck in a "drag is still in progress" state until remount. // // `isDragging` is still React state so we can turn off the CSS // `transition: width` for the duration of the gesture — otherwise the // 280ms tween from the collapse/expand animation would fight every // direct width write. const railRef = useRef(null); + const dragStateRef = useRef<{ + startX: number; + startWidth: number; + lastWidth: number; + pointerId: number; + } | null>(null); const [isDragging, setIsDragging] = useState(false); + const cleanupDrag = useCallback(() => { + dragStateRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + setIsDragging(false); + }, []); + const handleResizeStart = useCallback( - (e: React.MouseEvent) => { + (e: React.PointerEvent) => { if (collapsed) return; + // Only primary button; ignore right-click / middle-click. + if (e.button !== 0) return; e.preventDefault(); - const startX = e.clientX; - const startWidth = persistedWidth; - let lastWidth = startWidth; - setIsDragging(true); - const onMove = (me: MouseEvent) => { - const delta = me.clientX - startX; - const next = Math.min(RAIL_MAX_PX, Math.max(RAIL_MIN_PX, startWidth + delta)); - lastWidth = next; - if (railRef.current) railRef.current.style.width = `${next}px`; - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - // Commit final width to persisted settings exactly once per gesture. - // React will re-render with `style.width = persistedWidth`, which - // matches the DOM value we already wrote — no visual jump. - setPersistedWidth(lastWidth); - setIsDragging(false); + const target = e.currentTarget; + // Pointer capture guarantees this element receives pointermove / + // pointerup / pointercancel for the duration of the gesture, even + // when the cursor leaves the window. + try { + target.setPointerCapture(e.pointerId); + } catch { + // Some browsers throw if the pointer is already captured; ignore. + } + dragStateRef.current = { + startX: e.clientX, + startWidth: persistedWidth, + lastWidth: persistedWidth, + pointerId: e.pointerId, }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + setIsDragging(true); + }, + [collapsed, persistedWidth], + ); + + const handleResizeMove = useCallback((e: React.PointerEvent) => { + const drag = dragStateRef.current; + if (!drag || e.pointerId !== drag.pointerId) return; + const delta = e.clientX - drag.startX; + const next = Math.min(RAIL_MAX_PX, Math.max(RAIL_MIN_PX, drag.startWidth + delta)); + drag.lastWidth = next; + if (railRef.current) railRef.current.style.width = `${next}px`; + }, []); + + const handleResizeEnd = useCallback( + (e: React.PointerEvent) => { + const drag = dragStateRef.current; + if (!drag || e.pointerId !== drag.pointerId) return; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // Capture may already have been released by a pointercancel. + } + // Commit final width to persisted settings exactly once per gesture. + // React will re-render with `style.width = persistedWidth`, which + // matches the DOM value we already wrote — no visual jump. + setPersistedWidth(drag.lastWidth); + cleanupDrag(); }, - [collapsed, persistedWidth, setPersistedWidth], + [cleanupDrag, setPersistedWidth], ); useEffect( () => () => { + // Belt and suspenders: clear any document-level overrides on unmount. document.body.style.cursor = ''; document.body.style.userSelect = ''; }, @@ -228,6 +268,14 @@ export function SlideNavRail() { onClick: () => { const entry = useDeletedSceneRecycle.getState().consume(); if (!entry) return; + // Stage-scope guard: if the user has navigated to a + // different stage while the toast was up, the recycle + // entry belongs to the previous stage and `insertSceneAfter` + // would reject it on stage-id mismatch (silently losing the + // deleted scene). Drop the undo when stages don't match + // rather than blasting the entry into the wrong deck. + const currentStage = useStageStore.getState().stage; + if (!currentStage || currentStage.id !== entry.stageId) return; const live = useStageStore.getState().scenes; const anchorIndex = Math.min(Math.max(entry.index - 1, 0), live.length - 1); const anchor = live[anchorIndex]; @@ -289,11 +337,18 @@ export function SlideNavRail() { }} > {/* Resize handle — right edge, 6px hit zone, only enabled when - expanded. Matches the playback SceneSidebar drag handle. */} + expanded. Pointer Events with capture: once the gesture starts + this element owns the move/up/cancel stream regardless of + cursor location, so the rail can't get stuck in a "still + dragging" state on alt-tab / window blur / cursor-leaves- + window. */} {!collapsed && (
diff --git a/components/header.tsx b/components/header.tsx index 5f3979d689..c718668f00 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -28,10 +28,15 @@ export function Header({ currentSceneTitle, mode, canEdit, onToggleEditMode }: H > - {/* Title block — hidden in Pro mode (CommandBar shows the - scene title down below; double-stacking that title was the - "层叠割裂" complaint). The back button + right-side pill + - Pro Switch stay visible in both modes. */} + {/* Title block — hidden when `mode === 'edit'`. Header lives + inside `PlaybackChromeRoot`, which is unmounted by `Stage` + once mode flips to 'edit', so in steady state this branch + is always taken. The guard exists for the ~280ms + AnimatePresence exit window where the playback chrome + is still rendering its exit animation while `mode` has + already flipped — without the guard, this title would + briefly stack on top of the incoming EditChromeRoot's + CommandBar title during the cross-fade. */} {mode !== 'edit' && (
diff --git a/lib/edit/scene-editor-surface.ts b/lib/edit/scene-editor-surface.ts index d576a01347..0799d350be 100644 --- a/lib/edit/scene-editor-surface.ts +++ b/lib/edit/scene-editor-surface.ts @@ -90,6 +90,14 @@ export interface SurfaceHistory { redo: () => void; } +/** + * **Maintenance note:** any new field added here that the chrome reads + * must also be added to `surfaceStateEqual` in + * `components/edit/EditShell/EditShell.tsx`. Surface hooks return a + * fresh object each render, so semantic equality is the gate that + * prevents an infinite publish loop — a new field outside the + * comparison goes silently stale in the rendered chrome. + */ export interface SurfaceState { content: TContent; selection: TSelection; From 186cdf7ac41ef5ba0a0ac1de4f5d004c4437587b Mon Sep 17 00:00:00 2001 From: wyuc Date: Tue, 26 May 2026 12:10:45 -0400 Subject: [PATCH 13/14] =?UTF-8?q?docs(maic-editor):=20CR-loop=20round=202?= =?UTF-8?q?=20minors=20=E2=80=94=20sharpen=20JSDocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 reviewer flagged two doc-only refinements: - `surfaceStateEqual`: clarify that callback identity (`onInvoke`, `popoverContent`) is intentionally NOT compared, and that today's safety comes from slide-surface returning `floatingActions: []` rather than the per-item compare covering callbacks. A future surface that emits closure-capturing actions must fold its own change signal into the comparison or the stale callback fires at click time. - `setPointerCapture` catch: spell out that this is paranoia, not a real fallback — if capture genuinely fails the gesture still tracks for in-window moves but out-of-window `pointerup` won't route here. Acceptable degradation; the catch exists only because the spec permits an `InvalidPointerId` throw that browsers we ship to don't actually emit on same-pointer `pointerdown`. No functional changes. Co-Authored-By: Claude Opus 4.7 --- components/edit/EditShell/EditShell.tsx | 11 +++++++++++ components/edit/SlideNavRail/SlideNavRail.tsx | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/components/edit/EditShell/EditShell.tsx b/components/edit/EditShell/EditShell.tsx index 442de49b57..273cdb152d 100644 --- a/components/edit/EditShell/EditShell.tsx +++ b/components/edit/EditShell/EditShell.tsx @@ -152,6 +152,17 @@ function SurfaceStateRunner({ * - InsertPaletteItem / EditorCommand / FloatingAction arrays: length + * per-item `id` + per-item flags that drive visual state. * - EditorHint compared by length + per-item severity/message. + * + * **Callback identity (`onInvoke`, `popoverContent`) is intentionally + * NOT compared.** Per-render closure rebinding is normal and would + * trip equality every render. Today this is safe because slide is the + * only registered surface and its `floatingActions` is `[]` — the only + * `onInvoke` set the chrome reads is on `InsertPaletteItem`, which is + * a stable module-level closure from `buildInsertItems`. A future + * surface that returns non-empty `floatingActions` with closures + * capturing per-render state must fold its own change signal (a + * content ref / version counter) into the comparison, otherwise the + * stale callback fires at click time. */ function surfaceStateEqual(a: SurfaceState, b: SurfaceState | null): boolean { if (!b) return false; diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx index eca4315876..77e1241933 100644 --- a/components/edit/SlideNavRail/SlideNavRail.tsx +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -99,7 +99,14 @@ export function SlideNavRail() { try { target.setPointerCapture(e.pointerId); } catch { - // Some browsers throw if the pointer is already captured; ignore. + // Spec-wise `setPointerCapture` can only throw `InvalidPointerId`, + // which shouldn't happen inside the same pointer's `pointerdown`. + // This catch is paranoia, NOT a real fallback: if capture + // genuinely fails the gesture still tracks for in-window moves + // but `pointerup` outside the handle's bbox won't route here and + // the rail will stay in `isDragging` until SlideNavRail + // unmounts. The pointermove path remains useful so dropping the + // throw on the floor is preferable to bailing the gesture. } dragStateRef.current = { startX: e.clientX, From 9f4f0e0fecf4cd769a65f44bfcc6c8a2e32b8a7d Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 27 May 2026 09:24:54 -0400 Subject: [PATCH 14/14] fix(maic-editor): undo restore at index 0, reset mode on classroom load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from PR #601 manual-verification review: **Undo of the first slide restored it as the second.** The toast undo handler clamps `entry.index - 1` to 0 then calls `insertSceneAfter(scenes[0], entry.scene)`, which lands the entry at position 1 instead of position 0 — no scene exists before scenes[0] to anchor on. Fall back to `setScenes([entry.scene, ...live])` when `entry.index === 0` (or when the deck is empty). The store's existing non-rebalancing `deleteScene` keeps the surviving scenes at orders 2..N, so the prepended entry's original order=1 lines up naturally; StageGrid auto-selects the restored scene as current. **`mode` survived SPA navigation between classrooms.** Refresh reset mode to 'playback' via the initial store value, but switching classrooms via Next.js navigation kept the zustand singleton intact; entering Pro mode in A and then opening B left B in edit mode. `loadFromStorage` and the server-side classroom-load path both now set `mode: 'playback'` on every classroom load, normalising the SPA path to match the refresh path. Mode stays transient UI state, not persisted with the stage. e2e: delete Slide 1 → Undo → restored to position 1 (was position 2 before fix). Co-Authored-By: Claude Opus 4.7 --- app/classroom/[id]/page.tsx | 5 +++++ components/edit/SlideNavRail/SlideNavRail.tsx | 14 ++++++++++---- lib/store/stage.ts | 7 +++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index 14a6f82e4b..6f0cc4ea43 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -49,6 +49,11 @@ export default function ClassroomDetailPage() { useStageStore.setState({ scenes, currentSceneId: scenes[0]?.id ?? null, + // Match `loadFromStorage` semantics: mode is transient UI + // state, not persisted with the stage. Reset on every + // classroom load so SPA navigation doesn't carry Pro + // mode across. + mode: 'playback', }); log.info('Loaded from server-side storage:', classroomId); diff --git a/components/edit/SlideNavRail/SlideNavRail.tsx b/components/edit/SlideNavRail/SlideNavRail.tsx index 77e1241933..114d9e495f 100644 --- a/components/edit/SlideNavRail/SlideNavRail.tsx +++ b/components/edit/SlideNavRail/SlideNavRail.tsx @@ -284,13 +284,19 @@ export function SlideNavRail() { const currentStage = useStageStore.getState().stage; if (!currentStage || currentStage.id !== entry.stageId) return; const live = useStageStore.getState().scenes; - const anchorIndex = Math.min(Math.max(entry.index - 1, 0), live.length - 1); - const anchor = live[anchorIndex]; - if (!anchor) { - useStageStore.getState().setScenes([entry.scene]); + // Prepend path — `insertSceneAfter` requires an anchor, but + // restoring index 0 (the previously-first slide) has no + // predecessor to anchor on. Clamping `entry.index - 1` to 0 + // and inserting after `live[0]` would land the entry at + // position 1 instead of 0. setScenes-with-rebalance + // preserves the original "first slide" semantics. + if (entry.index === 0 || live.length === 0) { + useStageStore.getState().setScenes([entry.scene, ...live]); useStageStore.getState().setCurrentSceneId(entry.scene.id); return; } + const anchorIndex = Math.min(entry.index - 1, live.length - 1); + const anchor = live[anchorIndex]; useStageStore.getState().insertSceneAfter(anchor.id, entry.scene); useStageStore.getState().setCurrentSceneId(entry.scene.id); }, diff --git a/lib/store/stage.ts b/lib/store/stage.ts index 0917132897..ace4ab8ea8 100644 --- a/lib/store/stage.ts +++ b/lib/store/stage.ts @@ -330,6 +330,13 @@ const useStageStoreBase = create()((set, get) => ({ outlines, // Compute generatingOutlines from persisted outlines minus completed scenes generatingOutlines: outlines.filter((o) => !data.scenes.some((s) => s.order === o.order)), + // `mode` is transient UI state, not persisted with the stage. + // Reset to 'playback' on every load so SPA navigation between + // classrooms doesn't carry Pro-mode state across — e.g. user + // enters edit in A, navigates to B → B was inheriting + // mode='edit'. Refresh already reset via initial store value; + // this normalises the SPA path to match. + mode: 'playback', }); log.info('Loaded from storage:', stageId); } else {