Files
learnharness/components/sidebar.tsx
T
amosblomqvist 9a6d422d59 feat: merged title bar — drag bands, owner's mark, title-width rename hit-box
The system title bar disappears (titleBarStyle hiddenInset +
trafficLightPosition {16,24}): the app's own 60px header bands become the
window's drag surface, and the traffic lights float over the sidebar's
mark band, vertically centered on the same line as the chat header's
phase pill (pixel-verified: circle centers y 24–36.5pt, pill box center
30pt).

- electron/main.cjs: hiddenInset chrome; dev shell (LH_DEV_URL) gets its
  own userData (-dev suffix) so it never fights the packaged app's
  single-instance lock — verified: the lock silently killed the dev
  shell while the packaged app was open (that also proved the lock works
  across separately-launched builds).
- app/globals.css: .lh-electron-gated utilities — drag/no-drag bands,
  92px traffic-light gutter (.lh-lights-gutter / .lh-closed-toggle:
  lights end ~75pt + the lights' own 16px edge inset as the gap),
  home's floating strip becomes a drag surface only in the shell.
  Everything is inert in the plain browser.
- sidebar.tsx: SidebarProvider sets <html class=lh-electron> post-mount
  (UA detection; no hydration mismatch, absent in browser); LH wordmark
  text replaced by the owner's mark (components/lh-mark.tsx, inlined
  icon-no-padding.svg, 24px, home link kept); SidebarToggle gains the
  92px margin when closed so it never slides under the static lights.
- chat.tsx: the rename hit-box shrinks to the title text itself —
  flex-1 spanned the whole bar, so every click started a rename and the
  window lost its grip. Shrink-to-fit + a flex-1 drag spacer (chips stay
  right) + hover:bg-accent affordance (clear when hover renames vs
  drags; -mx/px cancel for zero shift). The renaming input keeps flex-1
  for edit room.
- home.tsx / new-session.tsx / phase-divider.tsx: drag bands + carve-outs.

Live-verified in the dev shell (pixel analysis) and headless browser:
lights/pill/mark centered, 17pt lights→mark gap, rename click→select-all
→Escape roundtrip, browser layout untouched.

Gates: typecheck, eslint, 500/500 tests, build.
2026-09-17 16:55:07 +03:00

350 lines
13 KiB
TypeScript

"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock";
import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner";
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";
const SidebarContext = createContext<{ open: boolean; toggle: () => void }>({
open: false,
toggle: () => {},
});
export function useSidebar() {
return useContext(SidebarContext);
}
export function SidebarProvider({ children }: { children: React.ReactNode }) {
// Start closed so server and client first-render agree (no hydration
// mismatch); the stored value is applied after mount.
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// 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 (typeof navigator !== "undefined" && navigator.userAgent.includes("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);
}, []);
const toggle = useCallback(() => {
setOpen((v) => {
localStorage.setItem(STORAGE_KEY, v ? "0" : "1");
return !v;
});
}, []);
return (
<SidebarContext.Provider value={{ open, 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",
)}
>
<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>
</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,
}: {
chat: ChatListItem;
active: boolean;
onOpen: () => void;
}) {
return (
<button
type="button"
onClick={onOpen}
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",
)}
>
<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>
);
}
function SessionsSidebar() {
const { open, toggle } = useSidebar();
const [chats, setChats] = useState<ChatListItem[] | null>(null);
const [query, setQuery] = useState("");
const pathname = usePathname();
const router = useRouter();
/* New session (owner, 2026-09-13): NO POST — the button opens the
sessionless draft view; the session is created only when the first
message is sent (and that message becomes its title). Search: a
client-side filter over the poll snapshot — no server round trip. */
const newSession = useCallback(() => {
router.push("/chat/new");
}, [router]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!chats) return chats;
if (!q) return chats;
return chats.filter((c) => c.title.toLowerCase().includes(q));
}, [chats, query]);
const refresh = useCallback(async () => {
try {
const res = await fetch("/api/chats", { cache: "no-store" });
const d = (await res.json()) as { chats: ChatListItem[] };
setChats(d.chats);
} catch {
// a transient failure on the periodic poll keeps the last snapshot
setChats((prev) => prev ?? []);
}
}, []);
/* 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);
}, []);
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. */}
<Link
href="/"
aria-label="Home"
className="lh-no-drag shrink-0 rounded-md transition-opacity hover:opacity-80"
>
<LhMark className="size-6" />
</Link>
<Button
variant="ghost"
size="icon"
aria-label="Close sidebar"
onClick={toggle}
className="lh-no-drag"
>
<PanelLeftCloseIcon className="size-4" />
</Button>
</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>
<span className="relative flex-1">
<Input
data-testid="session-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
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>
</div>
<div 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) => (
<SessionRow
key={c.id}
chat={c}
active={pathname === `/chat/${c.id}`}
onOpen={() => router.push(`/chat/${c.id}`)}
/>
))
)}
</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 />
</div>
</aside>
);
}
/** The app frame: sidebar inline + content. Rendered once in the root layout. */
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<>
<SessionsSidebar />
<main className="min-w-0 flex-1">{children}</main>
</>
);
}