/** * 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 { Session } from "@shared/session"; import { useActiveSession, useSessionListTick, useSessionService } from "@work/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); 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); } }; window.addEventListener("mousedown", handler); return () => window.removeEventListener("mousedown", handler); }, [open]); 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]); return (
{open && (
{sessions.length > 0 && ( <>
Switch to…
{sessions.map((s) => ( ))}
)} {!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", };