"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react"; import { ActivitySection } from "@/components/activity-dock"; import { BrailleSpinner } from "@/components/braille-spinner"; 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"; 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 [mounted, setMounted] = useState(false); useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); // Default: open (first run introduces the sidebar). setOpen(stored === null ? true : stored === "1"); setMounted(true); }, []); const toggle = useCallback(() => { setOpen((v) => { localStorage.setItem(STORAGE_KEY, v ? "0" : "1"); return !v; }); }, []); return ( {/* transitions gated on mount: no slide-in flash on page load */}
{children}
); } /** Hamburger for page headers — only visible when the sidebar is closed. */ export function SidebarToggle() { const { open, toggle } = useSidebar(); if (open) return null; 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, }: { chat: ChatListItem; active: boolean; onOpen: () => void; }) { return ( ); } function SessionsSidebar() { const { open, toggle } = useSidebar(); const [chats, setChats] = useState(null); const [query, setQuery] = useState(""); const pathname = usePathname(); const router = useRouter(); /* 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 ?? []); } }, []); 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}
); }