"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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ChatListItem, Phase3 } 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 (
);
}
/** Phase colors for the session dot — one system with the header chip
* and the transcript dividers (globals.css --phase-*). Static class
* strings per phase: Tailwind can't compile dynamic class names. */
const PHASE_FILL: Record = {
probe: "bg-phase-probe",
plan: "bg-phase-plan",
teach: "bg-phase-teach",
};
const PHASE_RING: Record = {
probe: "border-phase-probe",
plan: "border-phase-plan",
teach: "border-phase-teach",
};
/**
* The session dot, left of the title. TWO channels, one dot: COLOR is the
* phase (probe/plan/teach — sky/violet/emerald), FILL+MOTION is what's
* owed. Pulsing = the teacher is working (rare, time-sensitive — the only
* state that deserves motion). Solid = a question awaits the learner's
* answer (the DOMINANT resting state of real sessions — calm by design,
* no animation). Hollow = nothing owed, idle. A pending answer outranks
* "working": if a question is owed, that's the blocking fact.
*/
function SessionDot({ phase, pending, busy }: { phase: Phase3; pending: boolean; busy: boolean }) {
if (pending) {
return (
Waiting for your answer
);
}
if (busy) {
return (
Teacher is working
);
}
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. 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`;
return new Date(iso).toLocaleDateString();
}
/** The row's second line: state replaces the message count when the state
* is the more useful fact (most resting sessions are awaiting an answer —
* that's what the learner scans for). */
function sessionMeta(chat: ChatListItem): string {
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
if (chat.busy) return "Teacher is working…";
return `${chat.messageCount} messages · ${new Date(chat.updatedAt).toLocaleDateString()}`;
}
/**
* One session row: the status dot, 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}
>
);
}