Files
learnharness/components/sidebar.tsx
T
amosblomqvist 6b83d3248b Tooltip subtlety round: 500ms rest delay, quiet chip, parens suffixes, drops + trims
Owner round 2026-09-25, over 09c0f6e. The wave's tooltips read as
distracting; this round makes them quiet:

- Delay: TOOLTIP_DELAY_MS = 500 in the primitive — tooltips appear when
  you pause on a control, never in passing (Radix focus-opens stay
  instant by design; keyboard users don't wait).
- Vanish on leave: disableHoverableContent — the chip is un-hoverable,
  so the instant the cursor leaves the trigger it's gone, even mid-move
  onto the chip's own pixels.
- Look: bg-accent (#2a2a2a, the hover-chip tone) instead of the white
  bg-foreground slab; 11px; px-2 py-1; arrow culled (nothing purely
  decorative); text-foreground/85.
- Suffix: parentheses, not em-dash — "Dictate (⌘D)"; the registry
  source unchanged (still override-aware).
- Drops (owner-selected): send square, start square, stats summary —
  the icons/numbers describe themselves.
- Home logo kept but to the RIGHT with its real shortcut "Home (⌘G)"
  (jump-graph routes to /, the same destination).
- Trims: "Model selector (⌘K)", "Thinking level", "Graph costs —
  click for details", "How much of this session isn't mapped yet".
- Added: Import Notes gets "Import notes into the graph" — the one
  button whose destination wasn't obvious.

Pins: TOOLTIP_DELAY_MS=500; hintText parens expectations updated.
Live-verified on :3001 — 508ms hover delay measured, close within 10ms
of pointerout, chip styles probed (rgb(42,42,42), 11px, 4px/8px), arrow
gone, all new labels + sides confirmed, drops carry no tooltip and no
aria-describedby.
2026-09-25 02:26:37 +03:00

742 lines
29 KiB
TypeScript

"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";
/**
* 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 (keyboard wave A): the global shortcut dispatches lh:toggle-sidebar;
// the context is the one place that owns open state, so it listens here.
useEffect(() => {
const onToggle = () => toggle();
window.addEventListener("lh:toggle-sidebar", onToggle);
return () => window.removeEventListener("lh:toggle-sidebar", onToggle);
}, [toggle]);
return (
<SidebarContext.Provider value={{ open: open && !narrow, toggle }}>
{/* transitions gated on mount: no slide-in flash on page load */}
<div className={cn("flex h-dvh", mounted && "[&_.sidebar-motion]:transition-[width,margin,opacity]")}>{children}</div>
</SidebarContext.Provider>
);
}
/** 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 (
<span
className={cn(
"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",
)}
>
<Hint label="Open sidebar" action="toggle-sidebar">
<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>
</Hint>
</span>
);
}
/**
* 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 <BrailleSpinner />;
}
/** 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<HTMLButtonElement>) => void;
}) {
return (
<button
type="button"
onClick={onOpen}
onContextMenu={onContextMenu}
data-testid={kbdSelected ? "session-row-kbd" : "session-row"}
className={cn(
"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",
kbdSelected && !active && "bg-accent/60 ring-1 ring-border",
)}
>
<SessionStatus busy={chat.busy} />
<span className="min-w-0 flex-1">
{/* 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. */}
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
</span>
</button>
);
}
/** 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"
className="relative mb-0.5 flex w-full items-center rounded-lg bg-accent px-3 py-2"
>
{/* 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">
<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"
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
/>
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
</span>
</div>
);
}
function SessionsSidebar() {
const { open, toggle } = useSidebar();
const [chats, setChats] = useState<ChatListItem[] | null>(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<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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<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],
);
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 (
<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">
{/* 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. */}
{/* Hover mirrors the ghost toggle buttons (owner ask, 2026-09-17):
same size-9 chip geometry, same hover:bg-accent treatment. */}
<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"
>
<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>
</div>
<button
type="button"
data-testid="new-session"
onClick={() => void newSession()}
className="mx-3 mb-1 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"
>
<PlusIcon className="size-3" />
New session
<span className="sr-only">create a new session</span>
</button>
<div className="mx-3 mb-1 flex items-center gap-2">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
<Hint label="Search sessions" action="sessions-search" side="bottom">
<span className="relative flex-1">
<Input
ref={searchRef}
data-testid="session-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
// 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.
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
e.currentTarget.blur();
}
}}
placeholder="Search…"
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>
</Hint>
</div>
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
{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>
) : (
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)}
/>
),
)
)}
</div>
{/* 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 />
{/* Settings entry (settings-app wave): same row language as
New session — quiet, bottom-pinned, opens the centered
dialog (also mod+, in the keyboard registry). */}
<div className="px-3 pb-3 pt-1">
<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>
</div>
</div>
{/* 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
cycling; the dismissal effect above owns the contract. */}
{ctxChatId !== null && (
<MenuShell
anchorRef={ctxAnchorRef}
testid="session-context-menu"
preferredSide="down"
menuClass="flex w-52 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
>
{/* 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. */}
<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>
<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>
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
<CoverageMenuBlock chatId={ctxChatId} />
</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}
/>
</aside>
);
}
/** The app frame: sidebar inline + content. Rendered once in the root layout. */
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<SettingsDialogProvider>
<SessionsSidebar />
<main className="min-w-0 flex-1">{children}</main>
{/* 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 />
</SettingsDialogProvider>
);
}