"use client"; import { translator, type Language } from "@/src/core"; import { useI18n } from "@/components/i18n"; 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 { DumbbellIcon, 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 { Hint } from "@/components/ui/hint"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { fuzzyScore } from "@/src/core"; import { useServerEvent, useServerResync } from "@/components/server-events"; import type { ChatListItem } from "@/src/core"; import { kbdEventForAction } from "@/src/core"; import { notifySettingsSaved } from "@/components/settings-signal"; /** * 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. */ import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core"; 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?: { /** Read-only host OS; determines the native-control gutter. */ platform?: string; chatgpt?: ChatGPTControlBridge; subscriptions?: NativeSubscriptionControlBridge; expandWindow: () => void; /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ onZoom?: (cb: (factor: number) => void) => () => void; getZoom?: () => Promise; setLanguage?: (language: "en" | "ru") => void; /** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */ onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void; installUpdate?: () => void; }; } } const SidebarContext = createContext<{ open: boolean; toggle: () => void }>({ open: false, toggle: () => {}, }); export function useSidebar() { return useContext(SidebarContext); } export function SidebarProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const bridge = window.lh?.chatgpt; if (!bridge) return; const off = bridge.onChange(() => notifySettingsSaved("keys")); notifySettingsSaved("keys"); // cover changes missed before this root-lifetime subscription return off; }, []); // 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"); document.documentElement.dataset.lhPlatform = window.lh?.platform ?? ""; } // THE ZOOM TRUTH (owner round 7, 2026-10-05): ⌘±/⌘0 zoom the UI // (Electron zoomFactor) while the native traffic lights stay // physical-fixed — the CSS gutter lengths divide by this factor so // their RENDERED size never moves. getZoom covers the first-paint // race; every later change arrives as an lh:zoom push. const setZoomVar = (factor: number) => document.documentElement.style.setProperty("--lh-zoom", String(factor)); const off = window.lh?.onZoom?.(setZoomVar); void window.lh?.getZoom?.().then(setZoomVar); const stored = localStorage.getItem(STORAGE_KEY); // Default: open (first run introduces the sidebar). setOpen(stored === null ? true : stored === "1"); setMounted(true); return () => off?.(); }, []); // 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: the global shortcut dispatches kbdEventForAction("toggle-sidebar") // and the context answers on exactly that name — the shared spelling // is the fix for the interlude bug (the listener sat on the pre- // convention name "lh:toggle-sidebar" while the dispatcher sent // "lh:kbd-toggle-sidebar", so ⌘B silently did nothing; owner // 2026-09-25). useEffect(() => { const event = kbdEventForAction("toggle-sidebar"); const onToggle = () => toggle(); window.addEventListener(event, onToggle); return () => window.removeEventListener(event, 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 { t } = useI18n(); 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 input" 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(), language: Language = "en"): string { const t = translator(language); const ms = now.getTime() - new Date(iso).getTime(); if (ms < 60_000) return t("just now"); const min = Math.floor(ms / 60_000); if (min < 60) return t("{v0}m ago", { v0: min }); const h = Math.floor(min / 60); if (h < 24) return t("{v0}h ago", { v0: h }); const d = Math.floor(h / 24); if (d < 7) return t("{v0}d ago", { v0: d }); const date = new Date(iso); if (language === "ru") return new Intl.DateTimeFormat("ru-RU").format(date); 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, language: Language = "en"): string { const t = translator(language); if (chat.compacting) return t("Compacting context · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) }); if (chat.pendingQuestion) return t("Waiting for input · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) }); if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) { return t("Problem waiting · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) }); } if (chat.busy) return t("Working…"); return timeAgo(chat.updatedAt, undefined, language); } /** * 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; }) { const { t, language } = useI18n(); 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 { t, language } = useI18n(); 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={t("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, language)}
); } /** The three-way kind filter (practice sessions, owner 2026-10-04): * Teaching / All / Practice — a client-side filter over the same * snapshot, persisted in localStorage, defaulting to All. */ export type SessionKindFilter = "teach" | "all" | "practice"; const KIND_FILTER_KEY = "lh:session-kind-filter"; function SessionsSidebar() { const { t } = useI18n(); const { open, toggle } = useSidebar(); const [chats, setChats] = useState(null); const [query, setQuery] = useState(""); const [kindFilter, setKindFilter] = useState("all"); useEffect(() => { const stored = window.localStorage.getItem(KIND_FILTER_KEY); if (stored === "teach" || stored === "all" || stored === "practice") setKindFilter(stored); }, []); const setFilter = useCallback((f: SessionKindFilter) => { setKindFilter(f); window.localStorage.setItem(KIND_FILTER_KEY, f); }, []); 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]); /* FUZZY (owner round 8, 2026-10-05): subsequence match ranked by * fuzzyScore — prefix beats word-boundary beats scattered. Empty query * keeps the natural list order (scores are all 0, sort is stable). */ const shown = useMemo(() => { const q = query.trim(); let list = chats; if (list && kindFilter !== "all") { list = list.filter((c) => (c.kind ?? "teach") === kindFilter); } if (!list) return list; if (!q) return list; return list .map((c) => ({ c, score: fuzzyScore(q, c.title) })) .filter((r): r is { c: (typeof list)[number]; score: number } => r.score !== null) .sort((a, b) => b.score - a.score) .map((r) => r.c); }, [chats, query, kindFilter]); const refreshInFlightRef = useRef(false); const refresh = useCallback(async () => { // In-flight coalesce (S6): event bursts may land several refresh // calls at once — one fetch serves them all; anything newer arrives // with the next event's fetch. if (refreshInFlightRef.current) return; refreshInFlightRef.current = true; 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 keeps the last snapshot setChats((prev) => prev ?? []); } finally { refreshInFlightRef.current = false; } }, []); /* 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); }, []); /* INSTANT TURN-STATE (owner, sidebar round 2026-10-02): the open session broadcasts lh:turn-state the instant its turn starts (submitted — the optimistic machine's own flip) and at settle — busy mirrors the server's own definition (isTurnRunning), so the patch is exact: spinner up the moment the action happens, cleared and refetched once at settle (title/order move with turns). The events channel is the backstop for everything only the server knows: other windows, wake turns in unopened sessions. A start on a chat not yet in the snapshot (a brand-new session) = one immediate refresh instead of a patch. */ const chatsRef = useRef(chats); chatsRef.current = chats; useEffect(() => { const onTurnState = (e: Event) => { const { id, running } = (e as CustomEvent<{ id: string; running: boolean }>).detail; if (running) { if (chatsRef.current?.some((c) => c.id === id)) { setChats((prev) => (prev ? prev.map((c) => (c.id === id ? { ...c, busy: true } : c)) : prev)); } else { void refresh(); // the brand-new session: it isn't in the snapshot yet } return; } setChats((prev) => (prev ? prev.map((c) => (c.id === id ? { ...c, busy: false } : c)) : prev)); void refresh(); // settle: the server snapshot is now truthful — land it now }; window.addEventListener("lh:turn-state", onTurnState); return () => window.removeEventListener("lh:turn-state", onTurnState); }, [refresh]); /* PUSH (architecture S6, 2026-10-02): the 2.5s poll is RETIRED — the last standing network poll in the app. List truth arrives as events: chats-changed (create/rename/delete — the list-SHAPE writes), and turn-started/turn-settled for EVERY chat (unfiltered: any chat's turn flips busy/order/phase in this list). Announced fetches ride the 250ms trailing debounce (the S4/S5 law); resync (reconnect / dev-restart) is immediate. lh:turn-state + lh:chat-renamed stay for same-tab INSTANTS — local beats any round-trip. */ const refreshRef = useRef(refresh); refreshRef.current = refresh; const eventDebounceRef = useRef | undefined>(undefined); const scheduleEventRefresh = () => { if (eventDebounceRef.current) clearTimeout(eventDebounceRef.current); eventDebounceRef.current = setTimeout(() => { eventDebounceRef.current = undefined; void refreshRef.current(); }, 250); }; useServerEvent("chats-changed", scheduleEventRefresh); useServerEvent("turn-started", scheduleEventRefresh); useServerEvent("turn-settled", scheduleEventRefresh); useServerEvent("compaction-changed", scheduleEventRefresh); useServerResync(() => void refreshRef.current()); useEffect( () => () => { if (eventDebounceRef.current) clearTimeout(eventDebounceRef.current); }, [], ); /* ── Keyboard seams (shortcut wave A, 2026-09-17; search-walk round 2026-10-10) ── ⌘⇧F focuses the search field — and THE SEARCH OWNS THE WALK: ↑/↓ cycle the filtered list (wrap — "cycle"), Enter opens the walked row, or the FIRST match when nothing is walked (owner, 2026-10-10 — the quick-open pattern: ⌘⇧F, type, Enter, done). The Alt+↑/↓/↵ global cycle was RETIRED the same round: barely used, felt laggy. The selection is a row index into `shown`, so the walk follows whatever the query + kind filter leave. 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]); // 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(); // Opening + navigation refetch; while open, the events channel owns // freshness (S6) — no interval, nothing standing. }, [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. */}
); }