2026-08-22 04:28:13 +03:00
|
|
|
"use client";
|
|
|
|
|
|
2026-09-17 20:47:23 +03:00
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
2026-08-22 04:28:13 +03:00
|
|
|
import Link from "next/link";
|
|
|
|
|
import { usePathname, useRouter } from "next/navigation";
|
2026-09-23 12:45:12 +03:00
|
|
|
import { MenuShell } from "@/components/menu-shell";
|
|
|
|
|
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
|
|
|
|
import type { ChatDeleteMode } from "@/src/core";
|
2026-10-04 22:01:42 +03:00
|
|
|
import { DumbbellIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
2026-09-14 11:56:22 +03:00
|
|
|
import { ActivitySection } from "@/components/activity-dock";
|
2026-09-24 15:26:22 +03:00
|
|
|
import { CoverageMenuBlock } from "@/components/extraction-coverage";
|
2026-09-17 16:55:07 +03:00
|
|
|
import { LhMark } from "@/components/lh-mark";
|
2026-09-15 17:01:01 +03:00
|
|
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
2026-09-17 20:47:23 +03:00
|
|
|
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
2026-09-19 22:26:19 +03:00
|
|
|
import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
|
2026-09-25 01:52:20 +03:00
|
|
|
import { Hint } from "@/components/ui/hint";
|
2026-08-22 04:28:13 +03:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-09-12 23:41:06 +03:00
|
|
|
import { Input } from "@/components/ui/input";
|
2026-08-22 04:28:13 +03:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-10-05 06:08:51 +03:00
|
|
|
import { fuzzyScore } from "@/src/core";
|
2026-10-02 17:38:48 +03:00
|
|
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
2026-09-15 17:01:01 +03:00
|
|
|
import type { ChatListItem } from "@/src/core";
|
2026-09-25 02:39:11 +03:00
|
|
|
import { kbdEventForAction } from "@/src/core";
|
2026-10-24 12:17:08 +03:00
|
|
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
2026-08-22 04:28:13 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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";
|
|
|
|
|
|
2026-09-17 18:57:02 +03:00
|
|
|
/*
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-10-25 00:27:52 +03:00
|
|
|
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
2026-09-17 18:57:02 +03:00
|
|
|
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. */
|
2026-10-05 05:42:27 +03:00
|
|
|
lh?: {
|
2026-10-11 10:12:13 +03:00
|
|
|
/** Read-only host OS; determines the native-control gutter. */
|
|
|
|
|
platform?: string;
|
2026-10-24 12:17:08 +03:00
|
|
|
chatgpt?: ChatGPTControlBridge;
|
2026-10-25 00:27:52 +03:00
|
|
|
subscriptions?: NativeSubscriptionControlBridge;
|
2026-10-05 05:42:27 +03:00
|
|
|
expandWindow: () => void;
|
|
|
|
|
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
|
|
|
|
onZoom?: (cb: (factor: number) => void) => () => void;
|
|
|
|
|
getZoom?: () => Promise<number>;
|
2026-10-10 14:11:19 +03:00
|
|
|
/** 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;
|
2026-10-05 05:42:27 +03:00
|
|
|
};
|
2026-09-17 18:57:02 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 04:28:13 +03:00
|
|
|
const SidebarContext = createContext<{ open: boolean; toggle: () => void }>({
|
|
|
|
|
open: false,
|
|
|
|
|
toggle: () => {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export function useSidebar() {
|
|
|
|
|
return useContext(SidebarContext);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
2026-10-24 12:17:08 +03:00
|
|
|
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;
|
|
|
|
|
}, []);
|
2026-08-22 04:28:13 +03:00
|
|
|
// Start closed so server and client first-render agree (no hydration
|
|
|
|
|
// mismatch); the stored value is applied after mount.
|
|
|
|
|
const [open, setOpen] = useState(false);
|
2026-09-17 18:57:02 +03:00
|
|
|
const [narrow, setNarrow] = useState(false);
|
2026-08-22 04:28:13 +03:00
|
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-09-17 16:55:07 +03:00
|
|
|
// 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.
|
2026-09-17 18:57:02 +03:00
|
|
|
if (IS_ELECTRON) {
|
2026-09-17 16:55:07 +03:00
|
|
|
document.documentElement.classList.add("lh-electron");
|
2026-10-11 10:12:13 +03:00
|
|
|
document.documentElement.dataset.lhPlatform = window.lh?.platform ?? "";
|
2026-09-17 16:55:07 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-05 05:42:27 +03:00
|
|
|
// 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);
|
|
|
|
|
|
2026-08-22 04:28:13 +03:00
|
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
|
|
|
// Default: open (first run introduces the sidebar).
|
|
|
|
|
setOpen(stored === null ? true : stored === "1");
|
|
|
|
|
setMounted(true);
|
2026-10-05 05:42:27 +03:00
|
|
|
return () => off?.();
|
2026-08-22 04:28:13 +03:00
|
|
|
}, []);
|
|
|
|
|
|
2026-09-17 18:57:02 +03:00
|
|
|
// 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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-22 04:28:13 +03:00
|
|
|
const toggle = useCallback(() => {
|
2026-09-17 18:57:02 +03:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-08-22 04:28:13 +03:00
|
|
|
setOpen((v) => {
|
|
|
|
|
localStorage.setItem(STORAGE_KEY, v ? "0" : "1");
|
|
|
|
|
return !v;
|
|
|
|
|
});
|
2026-09-17 18:57:02 +03:00
|
|
|
}, [narrow, open]);
|
2026-08-22 04:28:13 +03:00
|
|
|
|
2026-09-25 02:39:11 +03:00
|
|
|
// ⌘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).
|
2026-09-17 20:47:23 +03:00
|
|
|
useEffect(() => {
|
2026-09-25 02:39:11 +03:00
|
|
|
const event = kbdEventForAction("toggle-sidebar");
|
2026-09-17 20:47:23 +03:00
|
|
|
const onToggle = () => toggle();
|
2026-09-25 02:39:11 +03:00
|
|
|
window.addEventListener(event, onToggle);
|
|
|
|
|
return () => window.removeEventListener(event, onToggle);
|
2026-09-17 20:47:23 +03:00
|
|
|
}, [toggle]);
|
|
|
|
|
|
2026-08-22 04:28:13 +03:00
|
|
|
return (
|
2026-09-17 18:57:02 +03:00
|
|
|
<SidebarContext.Provider value={{ open: open && !narrow, toggle }}>
|
2026-08-22 04:28:13 +03:00
|
|
|
{/* transitions gated on mount: no slide-in flash on page load */}
|
2026-09-17 14:04:43 +03:00
|
|
|
<div className={cn("flex h-dvh", mounted && "[&_.sidebar-motion]:transition-[width,margin,opacity]")}>{children}</div>
|
2026-08-22 04:28:13 +03:00
|
|
|
</SidebarContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 14:04:43 +03:00
|
|
|
/** 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. */
|
2026-08-22 04:28:13 +03:00
|
|
|
export function SidebarToggle() {
|
|
|
|
|
const { open, toggle } = useSidebar();
|
|
|
|
|
return (
|
2026-09-17 14:04:43 +03:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
2026-09-17 16:55:07 +03:00
|
|
|
"sidebar-motion lh-no-drag relative flex h-9 shrink-0 overflow-hidden duration-200",
|
|
|
|
|
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
|
2026-09-17 14:04:43 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2026-09-25 01:52:20 +03:00
|
|
|
<Hint label="Open sidebar" action="toggle-sidebar">
|
2026-09-17 14:04:43 +03:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
aria-label="Open sidebar"
|
|
|
|
|
aria-hidden={open}
|
|
|
|
|
tabIndex={open ? -1 : 0}
|
|
|
|
|
onClick={toggle}
|
|
|
|
|
className={cn("sidebar-motion duration-200", open ? "pointer-events-none opacity-0" : "opacity-100")}
|
|
|
|
|
>
|
|
|
|
|
{/* Owner, round 6: size-4, SAME as the close glyph — the toggle
|
|
|
|
|
must not grow when the sidebar is closed (it did: size-5 here).
|
|
|
|
|
The small look is the standard, both directions. */}
|
|
|
|
|
<PanelLeftOpenIcon className="size-4" />
|
|
|
|
|
</Button>
|
2026-09-25 01:52:20 +03:00
|
|
|
</Hint>
|
2026-09-17 14:04:43 +03:00
|
|
|
</span>
|
2026-08-22 04:28:13 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 18:28:35 +03:00
|
|
|
/**
|
2026-09-15 17:01:01 +03:00
|
|
|
* 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
|
2026-10-23 06:14:33 +03:00
|
|
|
* the meta line's "Waiting for input" text carries it. Idle is the
|
2026-09-15 17:01:01 +03:00
|
|
|
* absence of a signal, by design.
|
2026-09-01 18:28:35 +03:00
|
|
|
*/
|
2026-09-15 17:01:01 +03:00
|
|
|
function SessionStatus({ busy }: { busy: boolean }) {
|
|
|
|
|
if (!busy) return null;
|
|
|
|
|
return <BrailleSpinner />;
|
2026-09-01 18:28:35 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 19:49:47 +03:00
|
|
|
/** 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
|
2026-09-17 13:48:04 +03:00
|
|
|
* 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. */
|
2026-09-01 19:49:47 +03:00
|
|
|
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`;
|
2026-09-17 13:48:04 +03:00
|
|
|
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}`;
|
2026-09-01 19:49:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-17 13:48:04 +03:00
|
|
|
/** 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. */
|
2026-09-01 19:49:47 +03:00
|
|
|
function sessionMeta(chat: ChatListItem): string {
|
2026-10-06 00:38:15 +03:00
|
|
|
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
|
2026-10-23 06:14:33 +03:00
|
|
|
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`;
|
2026-10-04 22:06:22 +03:00
|
|
|
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
|
|
|
|
|
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
|
|
|
|
|
}
|
2026-10-10 12:19:31 +03:00
|
|
|
if (chat.busy) return "Working…";
|
2026-09-17 13:48:04 +03:00
|
|
|
return timeAgo(chat.updatedAt);
|
2026-09-01 19:49:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 18:28:35 +03:00
|
|
|
/**
|
2026-09-15 17:01:01 +03:00
|
|
|
* One session row: the status spinner (only while working), then title +
|
|
|
|
|
* state-aware meta. Exported for render tests — pure function of the
|
|
|
|
|
* list item.
|
2026-09-01 18:28:35 +03:00
|
|
|
*/
|
|
|
|
|
export function SessionRow({
|
|
|
|
|
chat,
|
|
|
|
|
active,
|
|
|
|
|
onOpen,
|
2026-09-17 20:47:23 +03:00
|
|
|
kbdSelected = false,
|
2026-09-23 12:45:12 +03:00
|
|
|
onContextMenu,
|
2026-09-01 18:28:35 +03:00
|
|
|
}: {
|
|
|
|
|
chat: ChatListItem;
|
|
|
|
|
active: boolean;
|
|
|
|
|
onOpen: () => void;
|
2026-09-17 20:47:23 +03:00
|
|
|
/** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) —
|
|
|
|
|
* visually distinct from the active session, which stays filled. */
|
|
|
|
|
kbdSelected?: boolean;
|
2026-09-23 12:45:12 +03:00
|
|
|
/** Right-click opens the context menu (wave 6: Rename / Delete). */
|
|
|
|
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
2026-09-01 18:28:35 +03:00
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onOpen}
|
2026-09-23 12:45:12 +03:00
|
|
|
onContextMenu={onContextMenu}
|
|
|
|
|
data-testid={kbdSelected ? "session-row-kbd" : "session-row"}
|
2026-09-01 18:28:35 +03:00
|
|
|
className={cn(
|
2026-09-17 13:48:04 +03:00
|
|
|
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent",
|
|
|
|
|
active && "bg-accent",
|
2026-09-17 20:47:23 +03:00
|
|
|
kbdSelected && !active && "bg-accent/60 ring-1 ring-border",
|
2026-09-01 18:28:35 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2026-10-06 00:38:15 +03:00
|
|
|
<SessionStatus busy={chat.busy || chat.compacting} />
|
2026-09-01 18:28:35 +03:00
|
|
|
<span className="min-w-0 flex-1">
|
2026-09-17 13:13:26 +03:00
|
|
|
{/* Titles are the learner's/teacher's WORDS — Source Sans 3, the
|
|
|
|
|
same voice the words were typed in (every input is lh-prose);
|
|
|
|
|
the meta line below stays JetBrains Mono (machine data). One
|
|
|
|
|
rule, finally applied to the picker too (owner, typography
|
|
|
|
|
round): mono = chrome, Source Sans = human words. */}
|
2026-09-17 13:29:21 +03:00
|
|
|
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
|
2026-09-01 19:49:47 +03:00
|
|
|
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
2026-09-01 18:28:35 +03:00
|
|
|
</span>
|
2026-10-05 05:18:26 +03:00
|
|
|
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
|
|
|
|
|
was left of the name; right-aligned keeps the titles flush with
|
|
|
|
|
the teaching rows and reads cleaner. */}
|
|
|
|
|
{(chat.kind ?? "teach") === "practice" && (
|
|
|
|
|
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
|
|
|
|
|
)}
|
2026-09-01 18:28:35 +03:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 12:45:12 +03:00
|
|
|
/** 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<HTMLInputElement | null>(null);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = inputRef.current;
|
|
|
|
|
if (el) {
|
|
|
|
|
el.focus();
|
|
|
|
|
el.select();
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
data-testid="session-rename-row"
|
2026-09-23 18:53:51 +03:00
|
|
|
className="relative mb-0.5 flex w-full items-center rounded-lg bg-accent px-3 py-2"
|
2026-09-23 12:45:12 +03:00
|
|
|
>
|
2026-09-23 18:53:51 +03:00
|
|
|
{/* 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. */}
|
|
|
|
|
<span className="flex min-w-0 w-full flex-col">
|
2026-09-23 12:45:12 +03:00
|
|
|
<input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
value={draft}
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
onChange={(e) => 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"
|
2026-09-23 18:53:51 +03:00
|
|
|
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
|
2026-09-23 12:45:12 +03:00
|
|
|
/>
|
|
|
|
|
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 22:01:42 +03:00
|
|
|
/** 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";
|
|
|
|
|
|
2026-08-22 04:28:13 +03:00
|
|
|
function SessionsSidebar() {
|
|
|
|
|
const { open, toggle } = useSidebar();
|
2026-09-01 18:28:35 +03:00
|
|
|
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
2026-09-12 23:41:06 +03:00
|
|
|
const [query, setQuery] = useState("");
|
2026-10-04 22:01:42 +03:00
|
|
|
const [kindFilter, setKindFilter] = useState<SessionKindFilter>("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);
|
|
|
|
|
}, []);
|
2026-08-22 04:28:13 +03:00
|
|
|
const pathname = usePathname();
|
|
|
|
|
const router = useRouter();
|
2026-09-19 22:26:19 +03:00
|
|
|
const { open: openSettings } = useSettingsDialog();
|
2026-08-22 04:28:13 +03:00
|
|
|
|
2026-09-14 09:06:48 +03:00
|
|
|
/* 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");
|
2026-09-12 23:41:06 +03:00
|
|
|
}, [router]);
|
|
|
|
|
|
2026-10-05 06:08:51 +03:00
|
|
|
/* 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). */
|
2026-09-12 23:41:06 +03:00
|
|
|
const shown = useMemo(() => {
|
2026-10-05 06:08:51 +03:00
|
|
|
const q = query.trim();
|
2026-10-04 22:01:42 +03:00
|
|
|
let list = chats;
|
|
|
|
|
if (list && kindFilter !== "all") {
|
|
|
|
|
list = list.filter((c) => (c.kind ?? "teach") === kindFilter);
|
|
|
|
|
}
|
|
|
|
|
if (!list) return list;
|
|
|
|
|
if (!q) return list;
|
2026-10-05 06:08:51 +03:00
|
|
|
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);
|
2026-10-04 22:01:42 +03:00
|
|
|
}, [chats, query, kindFilter]);
|
2026-09-12 23:41:06 +03:00
|
|
|
|
2026-10-02 17:38:48 +03:00
|
|
|
const refreshInFlightRef = useRef(false);
|
2026-09-01 18:28:35 +03:00
|
|
|
const refresh = useCallback(async () => {
|
2026-10-02 17:38:48 +03:00
|
|
|
// 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;
|
2026-09-01 18:28:35 +03:00
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/chats", { cache: "no-store" });
|
|
|
|
|
const d = (await res.json()) as { chats: ChatListItem[] };
|
|
|
|
|
setChats(d.chats);
|
|
|
|
|
} catch {
|
2026-10-02 17:38:48 +03:00
|
|
|
// a transient failure keeps the last snapshot
|
2026-09-01 18:28:35 +03:00
|
|
|
setChats((prev) => prev ?? []);
|
2026-10-02 17:38:48 +03:00
|
|
|
} finally {
|
|
|
|
|
refreshInFlightRef.current = false;
|
2026-09-01 18:28:35 +03:00
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-09-17 14:28:20 +03:00
|
|
|
/* 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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-10-02 03:05:48 +03:00
|
|
|
/* 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
|
2026-10-02 17:38:48 +03:00
|
|
|
events channel is the backstop for everything only the server
|
2026-10-02 03:05:48 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-10-02 17:38:48 +03:00
|
|
|
/* 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<ReturnType<typeof setTimeout> | 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);
|
2026-10-06 00:38:15 +03:00
|
|
|
useServerEvent("compaction-changed", scheduleEventRefresh);
|
2026-10-02 17:38:48 +03:00
|
|
|
useServerResync(() => void refreshRef.current());
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
if (eventDebounceRef.current) clearTimeout(eventDebounceRef.current);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-10 09:43:12 +03:00
|
|
|
/* ── 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. */
|
2026-09-17 20:47:23 +03:00
|
|
|
const [kbdIndex, setKbdIndex] = useState<number | null>(null);
|
|
|
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2026-09-23 12:45:12 +03:00
|
|
|
/* ── Wave 6 chunk A: context menu + inline rename + deletion ──
|
2026-09-24 15:51:40 +03:00
|
|
|
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
|
2026-09-24 15:26:22 +03:00
|
|
|
right-clicked — assigned into a ref before the state flip so the
|
|
|
|
|
shell measures it on its first (hidden) pass. */
|
2026-09-23 12:45:12 +03:00
|
|
|
const [ctxChatId, setCtxChatId] = useState<string | null>(null);
|
|
|
|
|
const ctxAnchorRef = useRef<HTMLElement | null>(null);
|
|
|
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
|
|
|
const [deleting, setDeleting] = useState<ChatListItem | null>(null);
|
|
|
|
|
|
|
|
|
|
const openContext = (e: React.MouseEvent<HTMLButtonElement>, 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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-17 20:47:23 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-08-22 04:28:13 +03:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
2026-09-01 18:28:35 +03:00
|
|
|
void refresh();
|
2026-10-02 17:38:48 +03:00
|
|
|
// Opening + navigation refetch; while open, the events channel owns
|
|
|
|
|
// freshness (S6) — no interval, nothing standing.
|
2026-09-01 18:28:35 +03:00
|
|
|
}, [open, pathname, refresh]);
|
2026-08-22 04:28:13 +03:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<aside
|
|
|
|
|
className={cn("sidebar-motion shrink-0 overflow-hidden duration-200", open ? "w-72" : "w-0")}
|
|
|
|
|
aria-hidden={!open}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex h-full w-72 flex-col border-r bg-background">
|
2026-09-17 16:55:07 +03:00
|
|
|
{/* LH wordmark — the owner's header; close toggle sits at the edge.
|
|
|
|
|
Merged chrome (2026-09-17): in Electron this band is the
|
|
|
|
|
window's drag surface and starts after the traffic lights
|
|
|
|
|
(.lh-lights-gutter); in the browser those classes are inert. */}
|
|
|
|
|
<div className="lh-drag lh-lights-gutter flex items-center justify-between px-4 py-3">
|
|
|
|
|
{/* The owner's mark (2026-09-17) replaces the "LH" text — still the
|
|
|
|
|
home link, still vertically centered by the band's items-center.
|
|
|
|
|
The 92px .lh-lights-gutter keeps it clear of the traffic lights
|
|
|
|
|
with the lights' own edge-inset as the gap. */}
|
2026-09-17 17:18:41 +03:00
|
|
|
{/* Hover mirrors the ghost toggle buttons (owner ask, 2026-09-17):
|
|
|
|
|
same size-9 chip geometry, same hover:bg-accent treatment. */}
|
2026-09-25 02:26:37 +03:00
|
|
|
<Hint
|
|
|
|
|
// Kept (owner, subtlety round 2026-09-25) but to the RIGHT
|
|
|
|
|
// of the logo, carrying its real shortcut: ⌘G jumps to
|
|
|
|
|
// the graph view (keyboard-shortcuts routes it to "/",
|
|
|
|
|
// the same destination as this logo).
|
|
|
|
|
label="Home"
|
|
|
|
|
action="jump-graph"
|
|
|
|
|
side="right"
|
|
|
|
|
>
|
2026-09-25 01:52:20 +03:00
|
|
|
<Link
|
|
|
|
|
href="/"
|
|
|
|
|
aria-label="Home"
|
|
|
|
|
className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
|
|
|
|
|
>
|
|
|
|
|
<LhMark className="size-6" />
|
|
|
|
|
</Link>
|
|
|
|
|
</Hint>
|
|
|
|
|
<Hint label="Close sidebar" action="toggle-sidebar">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
aria-label="Close sidebar"
|
|
|
|
|
onClick={toggle}
|
|
|
|
|
className="lh-no-drag"
|
|
|
|
|
>
|
|
|
|
|
<PanelLeftCloseIcon className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</Hint>
|
2026-08-22 04:28:13 +03:00
|
|
|
</div>
|
|
|
|
|
|
2026-10-05 06:08:51 +03:00
|
|
|
{/* Two doors, STACKED (owner round 8, 2026-10-05 — was a
|
|
|
|
|
half/half row): full-width rows read cleaner and give the
|
|
|
|
|
labels room; teaching keeps the original draft route,
|
|
|
|
|
practice carries its mode. */}
|
2026-10-05 07:05:45 +03:00
|
|
|
<div className="mx-3 mb-2 flex flex-col gap-1.5">
|
2026-10-04 22:01:42 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="new-session"
|
|
|
|
|
onClick={() => void newSession()}
|
2026-10-05 06:08:51 +03:00
|
|
|
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
2026-10-04 22:01:42 +03:00
|
|
|
>
|
|
|
|
|
<PlusIcon className="size-3" />
|
|
|
|
|
Teaching
|
|
|
|
|
<span className="sr-only">create a new teaching session</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="new-session-practice"
|
|
|
|
|
onClick={() => router.push("/chat/new?mode=practice")}
|
2026-10-05 06:08:51 +03:00
|
|
|
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
2026-10-04 22:01:42 +03:00
|
|
|
>
|
|
|
|
|
<DumbbellIcon className="size-3" />
|
|
|
|
|
Practice
|
|
|
|
|
<span className="sr-only">create a new practice session</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-05 07:05:45 +03:00
|
|
|
<div className="mx-3 mb-2 flex items-center gap-2">
|
2026-09-17 13:43:26 +03:00
|
|
|
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
2026-09-25 01:52:20 +03:00
|
|
|
<Hint label="Search sessions" action="sessions-search" side="bottom">
|
2026-09-12 23:41:06 +03:00
|
|
|
<span className="relative flex-1">
|
|
|
|
|
<Input
|
2026-09-17 20:47:23 +03:00
|
|
|
ref={searchRef}
|
2026-09-12 23:41:06 +03:00
|
|
|
data-testid="session-search"
|
|
|
|
|
value={query}
|
2026-10-10 09:43:12 +03:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setQuery(e.target.value);
|
|
|
|
|
// The list reorders under the query every keystroke —
|
|
|
|
|
// a stale walk index would point at the wrong row.
|
|
|
|
|
setKbdIndex(null);
|
|
|
|
|
}}
|
|
|
|
|
// THE SEARCH OWNS THE WALK (owner, 2026-10-10 — the
|
|
|
|
|
// retired Alt+↑/↓/↵ cycle's successor): ↑/↓ cycle the
|
|
|
|
|
// filtered rows (wrap), Enter opens the walked row —
|
|
|
|
|
// or the FIRST match when nothing is walked (the
|
|
|
|
|
// quick-open pattern) — then blurs (focus returns to
|
|
|
|
|
// the app per the Enter law; the query stays for
|
|
|
|
|
// re-searching). Esc leaves the search (owner,
|
|
|
|
|
// keyboard wave): blur back to normal, and claim the
|
|
|
|
|
// key so the window Esc chain (dictation / abort)
|
|
|
|
|
// doesn't also fire.
|
2026-09-18 08:34:46 +03:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.currentTarget.blur();
|
2026-10-10 09:43:12 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Modified keys belong to the surface (⇧↵ etc.).
|
|
|
|
|
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
|
|
|
|
const n = shown?.length ?? 0;
|
|
|
|
|
if (n === 0) return;
|
|
|
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
|
|
|
// The rows move, not the caret.
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const dir = e.key === "ArrowDown" ? 1 : -1;
|
|
|
|
|
setKbdIndex((i) =>
|
|
|
|
|
i === null || i >= n ? (dir === 1 ? 0 : n - 1) : (i + dir + n) % n,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
const target = shown![kbdIndex !== null && kbdIndex < n ? kbdIndex : 0];
|
|
|
|
|
if (!target) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
router.push(`/chat/${target.id}`);
|
|
|
|
|
setKbdIndex(null);
|
|
|
|
|
e.currentTarget.blur();
|
2026-09-18 08:34:46 +03:00
|
|
|
}
|
|
|
|
|
}}
|
2026-09-25 01:52:20 +03:00
|
|
|
placeholder="Search…"
|
2026-09-12 23:41:06 +03:00
|
|
|
aria-label="Search sessions"
|
|
|
|
|
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
|
|
|
|
|
/>
|
|
|
|
|
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
|
|
|
|
|
</span>
|
2026-09-25 01:52:20 +03:00
|
|
|
</Hint>
|
2026-09-12 23:41:06 +03:00
|
|
|
</div>
|
|
|
|
|
|
2026-10-05 06:14:54 +03:00
|
|
|
{/* The three-way filter, HEADING THE LIST (owner round 8b,
|
|
|
|
|
2026-10-05 — moved below the search): it scopes what the
|
|
|
|
|
list shows, so it sits directly above it as a caption row;
|
|
|
|
|
search stays the top muscle-memory target. Order: Teaching /
|
|
|
|
|
Practice / All — the real modes first, "All" as the escape
|
|
|
|
|
hatch on the right, mirroring the create-buttons above.
|
|
|
|
|
Persisted client-side. */}
|
2026-10-05 07:13:53 +03:00
|
|
|
<div className="mx-3 flex items-center gap-0.5 rounded-md border border-border/60 bg-background p-0.5" data-testid="session-kind-filter">
|
2026-10-05 06:14:54 +03:00
|
|
|
{(["teach", "practice", "all"] as const).map((f) => (
|
|
|
|
|
<button
|
|
|
|
|
key={f}
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid={`kind-filter-${f}`}
|
|
|
|
|
onClick={() => setFilter(f)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex-1 rounded-sm px-2 py-1 text-[11px] uppercase tracking-wider transition-colors",
|
|
|
|
|
kindFilter === f
|
|
|
|
|
? "bg-accent text-foreground"
|
|
|
|
|
: "text-muted-foreground hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{f === "teach" ? "Teaching" : f === "all" ? "All" : "Practice"}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-09-17 20:47:23 +03:00
|
|
|
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
2026-09-12 23:41:06 +03:00
|
|
|
{shown === null ? (
|
|
|
|
|
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
|
|
|
|
|
) : shown.length === 0 ? (
|
|
|
|
|
<div className="px-2 py-4 text-xs text-muted-foreground">
|
|
|
|
|
{query.trim() ? "No sessions match." : "No sessions yet."}
|
|
|
|
|
</div>
|
2026-08-22 04:28:13 +03:00
|
|
|
) : (
|
2026-09-23 12:45:12 +03:00
|
|
|
shown.map((c, i) =>
|
|
|
|
|
renamingId === c.id ? (
|
|
|
|
|
<RenameRow
|
|
|
|
|
key={c.id}
|
|
|
|
|
chat={c}
|
|
|
|
|
onCommit={(next) => void commitRename(c.id, next)}
|
|
|
|
|
onCancel={() => setRenamingId(null)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<SessionRow
|
|
|
|
|
key={c.id}
|
|
|
|
|
chat={c}
|
|
|
|
|
active={pathname === `/chat/${c.id}`}
|
|
|
|
|
kbdSelected={i === kbdIndex}
|
|
|
|
|
onOpen={() => {
|
|
|
|
|
setKbdIndex(null);
|
|
|
|
|
router.push(`/chat/${c.id}`);
|
|
|
|
|
}}
|
|
|
|
|
onContextMenu={(e) => openContext(e, c)}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-08-22 04:28:13 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-09-14 11:56:22 +03:00
|
|
|
|
|
|
|
|
{/* Background agents (owner, sidebar round): the section is
|
|
|
|
|
ALWAYS here — empty means empty (the header sits alone); it
|
|
|
|
|
populates and grows when processes run. The session list is
|
|
|
|
|
flex-1, so this pins to the bottom. Sidebar closed = the
|
|
|
|
|
learner opted out (no ambient signal, user decision). */}
|
|
|
|
|
<ActivitySection />
|
2026-09-19 18:26:25 +03:00
|
|
|
|
2026-09-19 22:26:19 +03:00
|
|
|
{/* Settings entry (settings-app wave): same row language as
|
|
|
|
|
New session — quiet, bottom-pinned, opens the centered
|
|
|
|
|
dialog (also mod+, in the keyboard registry). */}
|
2026-09-19 18:26:25 +03:00
|
|
|
<div className="px-3 pb-3 pt-1">
|
2026-09-25 01:52:20 +03:00
|
|
|
<Hint label="Settings" action="open-settings" side="right">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => openSettings()}
|
|
|
|
|
data-testid="settings-entry"
|
|
|
|
|
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<SettingsIcon className="size-3" />
|
|
|
|
|
Settings
|
|
|
|
|
</button>
|
|
|
|
|
</Hint>
|
2026-09-19 18:26:25 +03:00
|
|
|
</div>
|
2026-09-14 11:56:22 +03:00
|
|
|
</div>
|
2026-09-23 12:45:12 +03:00
|
|
|
|
2026-09-24 15:26:22 +03:00
|
|
|
{/* The context menu (wave 6 + the extraction-UX redesign): the
|
|
|
|
|
coverage block above, then exactly two management actions
|
|
|
|
|
(owner ruling — no more). MenuShell owns placement/focus/arrow
|
2026-09-23 12:45:12 +03:00
|
|
|
cycling; the dismissal effect above owns the contract. */}
|
|
|
|
|
{ctxChatId !== null && (
|
|
|
|
|
<MenuShell
|
|
|
|
|
anchorRef={ctxAnchorRef}
|
|
|
|
|
testid="session-context-menu"
|
|
|
|
|
preferredSide="down"
|
2026-09-24 15:26:22 +03:00
|
|
|
menuClass="flex w-52 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
|
2026-09-23 12:45:12 +03:00
|
|
|
>
|
2026-09-24 15:51:40 +03:00
|
|
|
{/* The two management actions first, then the extraction block
|
|
|
|
|
(owner refinement round, 2026-09-24): Rename/Delete stay at the
|
|
|
|
|
top, the coverage numbers + Extract affordance sit below the
|
|
|
|
|
hairline — the block is info + a stateful affordance, the
|
|
|
|
|
actions above it. Fetched on menu open (the block mounts WITH
|
|
|
|
|
the menu); 409 = benign. Renders nothing on a failed read. */}
|
2026-09-23 12:45:12 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="ctx-rename"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRenamingId(ctxChatId);
|
|
|
|
|
setCtxChatId(null);
|
|
|
|
|
}}
|
|
|
|
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
|
|
|
|
>
|
|
|
|
|
Rename
|
|
|
|
|
</button>
|
2026-10-06 10:23:16 +03:00
|
|
|
{/* Export as Markdown (owner, release polish 2026-10-06): the
|
|
|
|
|
session-markdown engine's user-facing debut — same-origin
|
|
|
|
|
anchor download; the route's Content-Disposition names the
|
|
|
|
|
file. Sits with the management actions, above Delete (the
|
|
|
|
|
destructive action stays last — the danger-fence law). */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="ctx-export-md"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const chatId = ctxChatId;
|
|
|
|
|
setCtxChatId(null);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = `/api/chats/${chatId}/markdown`;
|
|
|
|
|
a.download = ""; // the route's filename wins
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
a.remove();
|
|
|
|
|
}}
|
|
|
|
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
|
|
|
|
>
|
|
|
|
|
Export as Markdown…
|
|
|
|
|
</button>
|
2026-09-23 12:45:12 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-testid="ctx-delete"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const target = chats?.find((c) => c.id === ctxChatId);
|
|
|
|
|
if (target) setDeleting(target);
|
|
|
|
|
setCtxChatId(null);
|
|
|
|
|
}}
|
|
|
|
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
Delete…
|
|
|
|
|
</button>
|
2026-09-24 15:51:40 +03:00
|
|
|
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
|
|
|
|
|
<CoverageMenuBlock chatId={ctxChatId} />
|
2026-09-23 12:45:12 +03:00
|
|
|
</MenuShell>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* The two-option delete dialog (wave 6) — portaled to body
|
|
|
|
|
by the component itself, lives here for state locality. */}
|
|
|
|
|
<DeleteSessionDialog
|
|
|
|
|
chat={deleting}
|
|
|
|
|
onClose={() => setDeleting(null)}
|
|
|
|
|
onDeleted={onDeleted}
|
|
|
|
|
/>
|
2026-09-14 11:56:22 +03:00
|
|
|
</aside>
|
2026-08-22 04:28:13 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The app frame: sidebar inline + content. Rendered once in the root layout. */
|
|
|
|
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
|
|
|
|
return (
|
2026-09-19 22:26:19 +03:00
|
|
|
<SettingsDialogProvider>
|
2026-08-22 04:28:13 +03:00
|
|
|
<SessionsSidebar />
|
|
|
|
|
<main className="min-w-0 flex-1">{children}</main>
|
2026-09-17 20:47:23 +03:00
|
|
|
{/* 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. */}
|
|
|
|
|
<KeyboardShortcuts />
|
2026-09-19 22:26:19 +03:00
|
|
|
</SettingsDialogProvider>
|
2026-08-22 04:28:13 +03:00
|
|
|
);
|
|
|
|
|
}
|