"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { MenuShell } from "@/components/menu-shell"; import { DeleteSessionDialog } from "@/components/delete-session-dialog"; import type { ChatDeleteMode } from "@/src/core"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"; import { ActivitySection } from "@/components/activity-dock"; import { CoverageMenuBlock } from "@/components/extraction-coverage"; import { LhMark } from "@/components/lh-mark"; import { BrailleSpinner } from "@/components/braille-spinner"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import type { ChatListItem } from "@/src/core"; /** * Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It * sits inline next to the content and can stay open while working in a * session. Open state persists across pages (provider lives in the root * layout) and across reloads (localStorage). */ const STORAGE_KEY = "lh:sidebar-open"; /* * Narrow-window behavior (owner, 2026-09-17): below SIDEBAR_AUTOCLOSE the * sidebar would squeeze the page below the 640px window floor (the two * numbers compose: 640 + 288 sidebar = 928), so it auto-closes — and * auto-RESTORES when the window widens again (owner choice: the sidebar * pops back, not a manual re-open). The whole behavior is one expression: * rendered = pref && !narrow, where pref is the persisted user choice. * Manual closes persist and stay closed; auto-close does not touch the * preference. Opening the sidebar while narrow asks the Electron shell to * widen the window to the threshold (window.lh.expandWindow, IPC in * electron/main.cjs) — the resize listener flips `narrow` and the sidebar * appears. In the plain browser there is no window to widen and no * suppression — a narrow tab opens the sidebar if the user says so. * * Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync. */ const SIDEBAR_AUTOCLOSE = 928; // Module scope, guarded for SSR (the server has no navigator). const IS_ELECTRON = typeof navigator !== "undefined" && navigator.userAgent.includes("Electron"); declare global { interface Window { /** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */ lh?: { expandWindow: () => void }; } } const SidebarContext = createContext<{ open: boolean; toggle: () => void }>({ open: false, toggle: () => {}, }); export function useSidebar() { return useContext(SidebarContext); } export function SidebarProvider({ children }: { children: React.ReactNode }) { // Start closed so server and client first-render agree (no hydration // mismatch); the stored value is applied after mount. const [open, setOpen] = useState(false); const [narrow, setNarrow] = useState(false); const [mounted, setMounted] = useState(false); useEffect(() => { // Electron shell detection (merged chrome, 2026-09-17): keys the // .lh-electron CSS (app/globals.css) that turns the header bands // into the window's drag surface. Post-mount only — server HTML // and first client render must agree (no hydration mismatch), // and in the plain browser the class never appears. if (IS_ELECTRON) { document.documentElement.classList.add("lh-electron"); } const stored = localStorage.getItem(STORAGE_KEY); // Default: open (first run introduces the sidebar). setOpen(stored === null ? true : stored === "1"); setMounted(true); }, []); // Narrow-window auto-close/auto-restore (see SIDEBAR_AUTOCLOSE note): // fires only on actual resizes — a boolean compare, no layout work. // Both this and the mount effect land before the first paint, so a // narrow window never flashes the open sidebar on load. useEffect(() => { if (!IS_ELECTRON) return; // plain-browser tabs open freely const onResize = () => setNarrow(window.innerWidth < SIDEBAR_AUTOCLOSE); onResize(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); const toggle = useCallback(() => { // Opening while narrow (auto-closed, or the user's own closed-at- // narrow): ask the shell to widen to the threshold — the resize // listener flips `narrow` and the sidebar appears. The preference // persists like any other open, so a restart at a narrow width // still auto-restores when widened. if (narrow && !open) { localStorage.setItem(STORAGE_KEY, "0"); setOpen(true); window.lh?.expandWindow(); return; } setOpen((v) => { localStorage.setItem(STORAGE_KEY, v ? "0" : "1"); return !v; }); }, [narrow, open]); // ⌘B (keyboard wave A): the global shortcut dispatches lh:toggle-sidebar; // the context is the one place that owns open state, so it listens here. useEffect(() => { const onToggle = () => toggle(); window.addEventListener("lh:toggle-sidebar", onToggle); return () => window.removeEventListener("lh:toggle-sidebar", onToggle); }, [toggle]); return ( {/* transitions gated on mount: no slide-in flash on page load */}
{children}
); } /** Hamburger for page headers — only visible when the sidebar is closed. * * Owner, round 6c: the toggle is ALWAYS MOUNTED, owning a slot whose * width+margin animate in LOCKSTEP with the sidebar's own width * transition (same duration-200, same easing, both gated on mount) — * before, it mounted/unmounted in one frame, so its 48px footprint * (button + gap) popped and dragged the title and chips sideways while * the sidebar was still mid-slide. When open the slot is exactly zero * (w-0 -mr-3: width 0, the negative margin cancels the header's gap-3), * so resting layouts are pixel-identical to the old conditional render. * The icon fades with the slot and is clipped by it as it collapses — * no popping in either direction. */ export function SidebarToggle() { const { open, toggle } = useSidebar(); return ( ); } /** * The row's status indicator (owner, minimal-color round): the braille * spinner, ONLY while a teach turn is in flight — monochrome motion, the * terminal's own glyph, existing exactly as long as the work does and * vanishing the moment it ends. Every other state renders NOTHING: * phases only advance (all sessions end in Teaching — phase-as-signal was * decoration, now retired app-wide), and question-pending is the dominant * RESTING state of real sessions — a resting state has no scan value, so * the meta line's "Waiting for your answer" text carries it. Idle is the * absence of a signal, by design. */ function SessionStatus({ busy }: { busy: boolean }) { if (!busy) return null; return ; } /** Compact "how long ago": "just now", "5m ago", "3h ago", "2d ago", * then a plain date. How long a session has been WAITING matters more * than an absolute timestamp. Beyond a week the date renders ISO * (YYYY-MM-DD, zero-padded) — the international standard: unambiguous * in any locale and it sorts (owner, typography round 5; the old * M/D/YYYY was the US convention. A user-locale setting can come later * — trivial, a format function swap). Pure — exported for tests. */ export function timeAgo(iso: string, now = new Date()): string { const ms = now.getTime() - new Date(iso).getTime(); if (ms < 60_000) return "just now"; const min = Math.floor(ms / 60_000); if (min < 60) return `${min}m ago`; const h = Math.floor(min / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 7) return `${d}d ago`; const date = new Date(iso); const mm = String(date.getMonth() + 1).padStart(2, "0"); const dd = String(date.getDate()).padStart(2, "0"); return `${date.getFullYear()}-${mm}-${dd}`; } /** The row's second line: the state when a state exists (waiting/working), * otherwise just how long ago. The message count is DROPPED (owner, * typography round 5): it informed no decision — clutter. messageCount * still rides the API payload, so this is a one-line flip back if it's * ever wanted. */ function sessionMeta(chat: ChatListItem): string { if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`; if (chat.busy) return "Teacher is working…"; return timeAgo(chat.updatedAt); } /** * One session row: the status spinner (only while working), then title + * state-aware meta. Exported for render tests — pure function of the * list item. */ export function SessionRow({ chat, active, onOpen, kbdSelected = false, onContextMenu, }: { chat: ChatListItem; active: boolean; onOpen: () => void; /** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) — * visually distinct from the active session, which stays filled. */ kbdSelected?: boolean; /** Right-click opens the context menu (wave 6: Rename / Delete). */ onContextMenu?: (e: React.MouseEvent) => void; }) { return ( ); } /** The inline-rename row (wave 6): same geometry as SessionRow, but the * title line becomes a text input — select-all on entry (the click means * "replace", the header rename's rule), Enter/blur commits, Esc discards. * The meta line stays so the row never changes height mid-edit. */ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) { const [draft, setDraft] = useState(chat.title); const inputRef = useRef(null); useEffect(() => { const el = inputRef.current; if (el) { el.focus(); el.select(); } }, []); return (
{/* NOTHING MOVES (owner, polish round): an idle row renders no status spacer (SessionStatus is null when not busy), so the title starts at the row's own px-3 — 12px in. The old rename row always carried a 14px spacer + gap + the input's border and padding, landing the text ~41px in (and the meta with it). The input here is borderless and padding-free at the exact title x; the underline is the only editing cue. */} setDraft(e.target.value)} onBlur={() => onCommit(draft)} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") { e.stopPropagation(); // discard, not also the sidebar/chat chains onCancel(); } }} aria-label="Rename session" data-testid="session-rename-input" className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none" /> {sessionMeta(chat)}
); } function SessionsSidebar() { const { open, toggle } = useSidebar(); const [chats, setChats] = useState(null); const [query, setQuery] = useState(""); const pathname = usePathname(); const router = useRouter(); const { open: openSettings } = useSettingsDialog(); /* New session (owner, 2026-09-13): NO POST — the button opens the sessionless draft view; the session is created only when the first message is sent (and that message becomes its title). Search: a client-side filter over the poll snapshot — no server round trip. */ const newSession = useCallback(() => { router.push("/chat/new"); }, [router]); const shown = useMemo(() => { const q = query.trim().toLowerCase(); if (!chats) return chats; if (!q) return chats; return chats.filter((c) => c.title.toLowerCase().includes(q)); }, [chats, query]); const refresh = useCallback(async () => { try { const res = await fetch("/api/chats", { cache: "no-store" }); const d = (await res.json()) as { chats: ChatListItem[] }; setChats(d.chats); } catch { // a transient failure on the periodic poll keeps the last snapshot setChats((prev) => prev ?? []); } }, []); /* Instant rename propagation (2026-09-17): the chat header's rename commits dispatch lh:chat-renamed; patch the snapshot in place instead of waiting for the 2.5s poll. */ useEffect(() => { const onRenamed = (e: Event) => { const { id, title } = (e as CustomEvent<{ id: string; title: string }>).detail; setChats((prev) => (prev ? prev.map((c) => (c.id === id ? { ...c, title } : c)) : prev)); }; window.addEventListener("lh:chat-renamed", onRenamed); return () => window.removeEventListener("lh:chat-renamed", onRenamed); }, []); /* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns every session-list interaction, so the shortcuts land here: ⌘⇧F focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar if it's closed — the keys work from anywhere, like the toggle does); Alt+Enter opens the selected session. The selection is a row index into `shown`, so a typed query narrows the cycle to the matches. Mouse and keyboard coexist: hovering doesn't claim, a click or an open clears the selection. */ const [kbdIndex, setKbdIndex] = useState(null); const searchRef = useRef(null); const listRef = useRef(null); /* ── Wave 6 chunk A: context menu + inline rename + deletion ── Right-click a session row → MenuShell: the two management actions first (owner ruling), then — below a hairline — the extraction coverage block (owner-locked redesign 2026-09-24, order refined 2026-09-24: Rename/Delete on top, the numbers + manual Extract affordance at the bottom). The anchor is the row that was right-clicked — assigned into a ref before the state flip so the shell measures it on its first (hidden) pass. */ const [ctxChatId, setCtxChatId] = useState(null); const ctxAnchorRef = useRef(null); const [renamingId, setRenamingId] = useState(null); const [deleting, setDeleting] = useState(null); const openContext = (e: React.MouseEvent, chat: ChatListItem) => { e.preventDefault(); // suppress the native menu ctxAnchorRef.current = e.currentTarget; setCtxChatId(chat.id); }; // The menu contract (agent-section's): any pointerdown outside the menu // or its row closes it; Esc is CLAIMED so it never also aborts a turn // or folds the sidebar. Right-clicking another row re-anchors: its // pointerdown closes this menu, its contextmenu opens the next. useEffect(() => { if (ctxChatId === null) return; const onPointerDown = (e: PointerEvent) => { const target = e.target as Node; if (target instanceof Element && target.closest("[data-lh-menu]")) return; if (ctxAnchorRef.current?.contains(target)) return; setCtxChatId(null); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); setCtxChatId(null); } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [ctxChatId]); // Inline rename (owner ruling, wave 6): the row swaps to a text input; // commit rides the SAME plumbing as the header rename — PUT the title, // then lh:chat-renamed patches every listener (this sidebar's own // listener patches the snapshot instantly; an open chat header hears // it too). Esc discards; empty commit discards (the header's rules). const commitRename = useCallback(async (id: string, next: string) => { setRenamingId(null); const t = next.trim(); if (t === "") return; try { const res = await fetch(`/api/chats/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: t }), }); if (!res.ok) throw new Error(`rename failed: ${res.status}`); window.dispatchEvent(new CustomEvent("lh:chat-renamed", { detail: { id, title: t } })); } catch (err) { console.error("rename failed:", err); // keep the old title } }, []); // Delete success: patch the list instantly (the poll is the backstop), // navigate home if the open session died (the chat view unmounts — // drafts die with it, no orphan bubbles), and announce the graph // revision so the graph view refetches (deletion is the one writer // the status poll can't see — no run ever transitions some→none). const onDeleted = useCallback( (id: string, _mode: ChatDeleteMode, graphRevision: number) => { setDeleting(null); setChats((prev) => (prev ? prev.filter((c) => c.id !== id) : prev)); if (pathname === `/chat/${id}`) router.push("/"); window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { revision: graphRevision } })); }, [pathname, router], ); useEffect(() => { const onFocusSearch = () => { if (!open) toggle(); // A clipped (overflow-hidden) input still focuses — the sidebar's // slide-in doesn't gate the keystroke. requestAnimationFrame(() => searchRef.current?.focus()); }; window.addEventListener("lh:kbd-sessions-search", onFocusSearch); return () => window.removeEventListener("lh:kbd-sessions-search", onFocusSearch); }, [open, toggle]); useEffect(() => { const cycle = (dir: 1 | -1) => { setKbdIndex((i) => { const n = shown?.length ?? 0; if (n === 0) return null; if (i === null) return dir === 1 ? 0 : n - 1; return (i + dir + n) % n; }); }; const onPrev = () => { if (!open) toggle(); cycle(-1); }; const onNext = () => { if (!open) toggle(); cycle(1); }; const onOpenSelected = () => { const target = kbdIndex !== null ? shown?.[kbdIndex] : undefined; if (target) { router.push(`/chat/${target.id}`); setKbdIndex(null); } }; window.addEventListener("lh:kbd-cycle-session-prev", onPrev); window.addEventListener("lh:kbd-cycle-session-next", onNext); window.addEventListener("lh:kbd-open-session", onOpenSelected); return () => { window.removeEventListener("lh:kbd-cycle-session-prev", onPrev); window.removeEventListener("lh:kbd-cycle-session-next", onNext); window.removeEventListener("lh:kbd-open-session", onOpenSelected); }; // kbdIndex is read (not upserted) so Alt+Enter pushes OUTSIDE any // state updater — a router.push inside one is setState-in-render. }, [open, toggle, shown, router, kbdIndex]); // Keep the kbd-selected row in view (the rows are the container's only // buttons, so the index matches). useEffect(() => { if (kbdIndex === null) return; listRef.current?.querySelectorAll("button")[kbdIndex]?.scrollIntoView({ block: "nearest" }); }, [kbdIndex]); useEffect(() => { if (!open) return; void refresh(); // Poll while open: the statuses are live (a question lands in another // session, a turn finishes in the background) and the payload is tiny. // Pause while the tab is hidden; navigation refetches via pathname. const id = setInterval(() => { if (!document.hidden) void refresh(); }, 2500); return () => clearInterval(id); }, [open, pathname, refresh]); return ( ); } /** The app frame: sidebar inline + content. Rendered once in the root layout. */ export function AppShell({ children }: { children: React.ReactNode }) { return (
{children}
{/* The single document-level keydown listener (keyboard wave A) — after the content in the tree, but it's window-level so order only matters for the events it dispatches, never for DOM priority. */}
); }