/** * SessionMenu — top-bar dropdown that drives the SessionService. * * Holds the only place in the UI where sessions get created, renamed, * deleted, and switched. Export/Import ZIP menu items are slots — * T06/T07 wire them. * * Switching sessions writes the new id into the URL hash; the routing * layer is the source of truth (see `routing.ts`). That keeps deep * links + browser back/forward behaving naturally. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import type { SessionId } from "@shared/ids"; import type { Session } from "@shared/session"; import { useActiveSession, useSessionListTick, useSessionService } from "@work/index"; import { clearAllSessionData } from "@engine/index"; import { navigateTo } from "./routing"; interface SessionMenuProps { readonly onExportZip?: () => void; readonly onImportZip?: () => void; readonly onOpenSamples?: () => void; } export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: SessionMenuProps) { const service = useSessionService(); const tick = useSessionListTick(); const active = useActiveSession(); const [open, setOpen] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [pendingDelete, setPendingDelete] = useState(false); /** Per-row delete confirmation: id of the session armed for delete. */ const [pendingRowDelete, setPendingRowDelete] = useState(null); const [pendingResetAll, setPendingResetAll] = useState(false); const [error, setError] = useState(null); const wrapperRef = useRef(null); const sessions = useMemo(() => { // sorted by lastOpenedAt desc, then by createdAt desc void tick; const list = [...service.list()]; list.sort((a: Session, b: Session) => { const aKey = a.lastOpenedAt ?? a.createdAt; const bKey = b.lastOpenedAt ?? b.createdAt; return bKey.localeCompare(aKey); }); return list; }, [service, tick]); // Click outside closes the menu. useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (!wrapperRef.current) return; if (!wrapperRef.current.contains(e.target as Node)) { setOpen(false); setCreating(false); setRenaming(false); setPendingDelete(false); setPendingRowDelete(null); setPendingResetAll(false); } }; window.addEventListener("mousedown", handler); return () => window.removeEventListener("mousedown", handler); }, [open]); // Auto-clear pending row-delete after a few seconds so the user // doesn't accidentally double-click much later and lose data. useEffect(() => { if (!pendingRowDelete) return; const t = setTimeout(() => setPendingRowDelete(null), 3000); return () => clearTimeout(t); }, [pendingRowDelete]); useEffect(() => { if (!pendingResetAll) return; const t = setTimeout(() => setPendingResetAll(false), 5000); return () => clearTimeout(t); }, [pendingResetAll]); const switchTo = useCallback( (sessionId: import("@shared/ids").SessionId) => { navigateTo({ sessionId, mode: "review" }); setOpen(false); }, [], ); const handleCreate = useCallback(() => { setError(null); try { const created = service.create(newName); setNewName(""); setCreating(false); setOpen(false); navigateTo({ sessionId: created.id, mode: "review" }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, [newName, service]); const handleRename = useCallback(() => { if (!active) return; setError(null); try { service.rename(active.id, renameValue); setRenaming(false); setOpen(false); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, [active, renameValue, service]); const handleDelete = useCallback(() => { if (!active) return; if (!pendingDelete) { setPendingDelete(true); return; } service.delete(active.id); setPendingDelete(false); setOpen(false); navigateTo({ sessionId: null, mode: "review" }); }, [active, pendingDelete, service]); const handleRowDelete = useCallback( (e: React.MouseEvent, id: SessionId) => { e.stopPropagation(); if (pendingRowDelete !== id) { setPendingRowDelete(id); return; } const wasActive = active?.id === id; service.delete(id); setPendingRowDelete(null); if (wasActive) { setOpen(false); navigateTo({ sessionId: null, mode: "review" }); } }, [active, pendingRowDelete, service], ); const handleResetAll = useCallback(() => { if (!pendingResetAll) { setPendingResetAll(true); return; } clearAllSessionData(); setPendingResetAll(false); setOpen(false); // Force a full reload so every in-memory cache (sessions repo, // byte stores, engine snapshots) starts fresh from the cleared // localStorage. if (typeof window !== "undefined") { window.location.hash = ""; window.location.reload(); } }, [pendingResetAll]); return (
{open && (
{sessions.length > 0 && ( <>
Switch to…
{sessions.map((s) => { const rowArmed = pendingRowDelete === s.id; return (
); })}
)} {!creating && ( )} {creating && (
setNewName(e.target.value)} placeholder="Session name" data-testid="session-new-input" style={{ flex: 1, fontSize: 12, padding: 4 }} onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); if (e.key === "Escape") setCreating(false); }} />
)} {active && ( <>
{!renaming && ( )} {renaming && (
setRenameValue(e.target.value)} data-testid="session-rename-input" style={{ flex: 1, fontSize: 12, padding: 4 }} onKeyDown={(e) => { if (e.key === "Enter") handleRename(); if (e.key === "Escape") setRenaming(false); }} />
)} )} {(onExportZip || onImportZip || onOpenSamples) && (
)} {onExportZip && active && ( )} {onImportZip && ( )} {onOpenSamples && ( )}
{error && (
{error}
)}
)}
); } const menuItemStyle: CSSProperties = { display: "block", width: "100%", textAlign: "left", background: "transparent", border: "none", padding: "4px 8px", cursor: "pointer", fontSize: 12, }; const smallButtonStyle: CSSProperties = { fontSize: 12, padding: "2px 8px", border: "1px solid #888", background: "white", cursor: "pointer", }; const dividerStyle: CSSProperties = { border: "none", borderTop: "1px solid #eee", margin: "4px 0", };