From 05359301e2fa07e38ead600c92bb9925a3f2b302 Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 1 Sep 2026 18:28:35 +0300 Subject: [PATCH] Session picker: phase strips, status dots, live polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionRow (extracted, exported for tests): leftmost phase strip (bookmark tick, per-phase color: probe/plan/teach) + status dot left of the title — pulsing amber attention dot when an answer is owed, quiet breathing dot while the teacher works, nothing when it's the learner's turn. A pending answer outranks 'working'. - globals.css: phase + attention color tokens (one system shared with the upcoming header chip and dividers) and the two dot animations, both disabled under prefers-reduced-motion. - SessionsSidebar polls /api/chats every 2.5s while open (payload is tiny now that busy/phase/pendingQuestion come from the list) and pauses while the tab is hidden; fetch-on-navigation preserved. --- app/globals.css | 73 ++++++++++++++++++ components/sidebar.tsx | 141 ++++++++++++++++++++++++++-------- tests/render-sidebar.test.tsx | 79 +++++++++++++++++++ 3 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 tests/render-sidebar.test.tsx diff --git a/app/globals.css b/app/globals.css index a123a02..8f6c102 100644 --- a/app/globals.css +++ b/app/globals.css @@ -157,3 +157,76 @@ body { overflow-x: auto; overflow-y: hidden; } + +/* ============================================================ + * Session status — phase colors + status dots + * (picker strips, header chip, transcript dividers: one language) + * ============================================================ */ + +/* Phase colors: blue = probing, violet = planning, green = teaching. + Tuned per theme (light needs darker, dark lighter). The attention color + (an answer the learner owes) is deliberately outside the phase system — + dots and strips are separate channels and must not read as one. */ +:root { + --phase-probe: oklch(0.55 0.17 253); + --phase-plan: oklch(0.54 0.19 300); + --phase-teach: oklch(0.55 0.14 160); + --attention: oklch(0.62 0.15 65); +} + +.dark { + --phase-probe: oklch(0.72 0.13 250); + --phase-plan: oklch(0.72 0.14 300); + --phase-teach: oklch(0.74 0.12 160); + --attention: oklch(0.8 0.15 75); +} + +@theme inline { + --color-phase-probe: var(--phase-probe); + --color-phase-plan: var(--phase-plan); + --color-phase-teach: var(--phase-teach); + --color-attention: var(--attention); +} + +/* Status dots (session picker). Attention — a slow expanding ring, drawing + the eye to "your move". Working — a quiet breathing dot, opacity only, + never loud enough to compete with the attention ring. */ +@keyframes lh-dot-attention { + 0% { + box-shadow: 0 0 0 0 color-mix(in oklab, var(--attention) 60%, transparent); + } + + 70%, + 100% { + box-shadow: 0 0 0 5px transparent; + } +} + +.lh-dot-attention { + animation: lh-dot-attention 1.6s ease-out infinite; +} + +@keyframes lh-dot-breathe { + 0%, + 100% { + opacity: 0.35; + transform: scale(0.75); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + +.lh-dot-breathe { + animation: lh-dot-breathe 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + + .lh-dot-attention, + .lh-dot-breathe { + animation: none; + } +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index d705b13..9b27e5c 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation"; import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { ChatSummary } from "@/src/core"; +import type { ChatListItem, Phase3 } from "@/src/core"; /** * Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It @@ -65,23 +65,115 @@ export function SidebarToggle() { ); } +/** The left-edge phase strip color — one system with the header chip and + * the transcript dividers (globals.css --phase-*). */ +const PHASE_STRIP: Record = { + probe: "bg-phase-probe", + plan: "bg-phase-plan", + teach: "bg-phase-teach", +}; + +/** + * The session-status dot, left of the title. One visual family: a pulsing + * attention ring when an answer is owed (your move — the session is blocked + * on the learner), a quietly breathing dot while the teacher works (a turn + * running or a background job in flight), and NOTHING when it's simply the + * learner's turn to type — quiet is the correct default. A pending answer + * outranks "working": there is nothing else for the learner to do first. + */ +function SessionStatusDot({ pending, busy }: { pending: boolean; busy: boolean }) { + const label = pending ? "Waiting for your answer" : busy ? "Teacher is working" : null; + return ( + + {pending ? ( + + ) : busy ? ( + + ) : ( + /* empty slot: keeps every title on the same baseline */ + + )} + {label && {label}} + + ); +} + +/** + * One session row: the leftmost phase strip (bookmark tick, one color system + * with the header chip and transcript dividers), the status dot, then title + * + meta. Exported for render tests — pure function of the list item. + */ +export function SessionRow({ + chat, + active, + onOpen, +}: { + chat: ChatListItem; + active: boolean; + onOpen: () => void; +}) { + return ( + + ); +} + function SessionsSidebar() { const { open, toggle } = useSidebar(); - const [chats, setChats] = useState(null); + const [chats, setChats] = useState(null); const pathname = usePathname(); const router = useRouter(); + const refresh = useCallback(async () => { + try { + const res = await fetch("/api/chats", { cache: "no-store" }); + const d = (await res.json()) as { chats: ChatListItem[] }; + setChats(d.chats); + } catch { + // a transient failure on the periodic poll keeps the last snapshot + setChats((prev) => prev ?? []); + } + }, []); + useEffect(() => { if (!open) return; - let cancelled = false; - fetch("/api/chats", { cache: "no-store" }) - .then((r) => r.json()) - .then((d: { chats: ChatSummary[] }) => !cancelled && setChats(d.chats)) - .catch(() => !cancelled && setChats([])); - return () => { - cancelled = true; - }; - }, [open, pathname]); + 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 (