"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 { Hint } from "@/components/ui/hint";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ChatListItem } from "@/src/core";
import { kbdEventForAction } 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: 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 { 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. */}
);
}