"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
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 (
);
}
/** The left-edge phase strip color — one system with the header chip and
* the transcript dividers (globals.css --phase-*). */
const PHASE_STRIP: Record = {
probe: "bg-phase-probe",
plan: "bg-phase-plan",
teach: "bg-phase-teach",
};
/**
* The session-status dot, left of the title. One visual family: a pulsing
* attention ring when an answer is owed (your move — the session is blocked
* on the learner), a quietly breathing dot while the teacher works (a turn
* running or a background job in flight), and NOTHING when it's simply the
* learner's turn to type — quiet is the correct default. A pending answer
* outranks "working": there is nothing else for the learner to do first.
*/
function SessionStatusDot({ pending, busy }: { pending: boolean; busy: boolean }) {
const label = pending ? "Waiting for your answer" : busy ? "Teacher is working" : null;
return (
{pending ? (
) : busy ? (
) : (
/* empty slot: keeps every title on the same baseline */
)}
{label && {label}}
);
}
/**
* One session row: the leftmost phase strip (bookmark tick, one color system
* with the header chip and transcript dividers), the status dot, then title
* + 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 pathname = usePathname();
const router = useRouter();
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}
>
);
}