Session picker: phase strips, status dots, live polling
- 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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+111
-30
@@ -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<Phase3, string> = {
|
||||
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 (
|
||||
<span className="flex w-3 shrink-0 items-center justify-center" title={label ?? undefined}>
|
||||
{pending ? (
|
||||
<span
|
||||
data-testid="session-dot-attention"
|
||||
className="size-2 rounded-full bg-attention lh-dot-attention"
|
||||
/>
|
||||
) : busy ? (
|
||||
<span
|
||||
data-testid="session-dot-working"
|
||||
className="size-2 rounded-full bg-muted-foreground lh-dot-breathe"
|
||||
/>
|
||||
) : (
|
||||
/* empty slot: keeps every title on the same baseline */
|
||||
<span className="size-2" />
|
||||
)}
|
||||
{label && <span className="sr-only">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<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 text-sm transition-colors hover:bg-accent",
|
||||
active && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
data-testid={`phase-strip-${chat.phase}`}
|
||||
className={cn(
|
||||
"absolute top-2 bottom-2 left-0.5 w-[3px] rounded-full",
|
||||
PHASE_STRIP[chat.phase],
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<SessionStatusDot pending={chat.pendingQuestion} busy={chat.busy} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{chat.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{chat.messageCount} messages · {new Date(chat.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsSidebar() {
|
||||
const { open, toggle } = useSidebar();
|
||||
const [chats, setChats] = useState<ChatSummary[] | null>(null);
|
||||
const [chats, setChats] = useState<ChatListItem[] | null>(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 (
|
||||
<aside
|
||||
@@ -104,25 +196,14 @@ function SessionsSidebar() {
|
||||
) : chats.length === 0 ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
|
||||
) : (
|
||||
chats.map((c) => {
|
||||
const active = pathname === `/chat/${c.id}`;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/chat/${c.id}`)}
|
||||
className={cn(
|
||||
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
active && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<div className="truncate">{c.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
chats.map((c) => (
|
||||
<SessionRow
|
||||
key={c.id}
|
||||
chat={c}
|
||||
active={pathname === `/chat/${c.id}`}
|
||||
onOpen={() => router.push(`/chat/${c.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Session-row render tests — the picker's status language (phase strips +
|
||||
* status dots). Pure function of the list item, so SSR of <SessionRow> pins
|
||||
* every visible state. Same tsx classic-JSX shim as render-cards.test.tsx.
|
||||
*/
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { SessionRow } from "../components/sidebar.tsx";
|
||||
import type { ChatListItem, Phase3 } from "../src/core/index.ts";
|
||||
|
||||
function item(overrides: Partial<ChatListItem>): ChatListItem {
|
||||
return {
|
||||
id: "c1",
|
||||
title: "Test session",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
messageCount: 4,
|
||||
phase: "probe",
|
||||
pendingQuestion: false,
|
||||
busy: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(chat: ChatListItem, active = false) {
|
||||
return renderToStaticMarkup(<SessionRow chat={chat} active={active} onOpen={() => {}} />);
|
||||
}
|
||||
|
||||
describe("SessionRow", () => {
|
||||
it("renders a phase strip colored per phase (one testid per phase)", () => {
|
||||
const phases: Phase3[] = ["probe", "plan", "teach"];
|
||||
for (const phase of phases) {
|
||||
const html = render(item({ phase }));
|
||||
assert.ok(html.includes(`data-testid="phase-strip-${phase}"`), `${phase} strip present`);
|
||||
assert.ok(html.includes(`bg-phase-${phase}`), `${phase} strip carries its phase color`);
|
||||
}
|
||||
});
|
||||
|
||||
it("quiet state (learner's turn): no status dot", () => {
|
||||
const html = render(item({}));
|
||||
assert.ok(!html.includes("session-dot-attention"));
|
||||
assert.ok(!html.includes("session-dot-working"));
|
||||
});
|
||||
|
||||
it("a pending question renders the attention dot", () => {
|
||||
const html = render(item({ pendingQuestion: true }));
|
||||
assert.ok(html.includes("session-dot-attention"), "attention dot present");
|
||||
assert.ok(html.includes("bg-attention"), "attention dot uses the attention color");
|
||||
assert.ok(html.includes("Waiting for your answer"), "screen-reader + tooltip label");
|
||||
});
|
||||
|
||||
it("a working session renders the breathing dot", () => {
|
||||
const html = render(item({ busy: true }));
|
||||
assert.ok(html.includes("session-dot-working"), "working dot present");
|
||||
assert.ok(html.includes("lh-dot-breathe"), "breathing animation");
|
||||
assert.ok(html.includes("Teacher is working"), "label");
|
||||
});
|
||||
|
||||
it("a pending answer outranks 'working' when both are true", () => {
|
||||
const html = render(item({ pendingQuestion: true, busy: true }));
|
||||
assert.ok(html.includes("session-dot-attention"));
|
||||
assert.ok(!html.includes("session-dot-working"), "one dot at a time — attention wins");
|
||||
});
|
||||
|
||||
it("the title and meta line render", () => {
|
||||
const html = render(item({ title: "Partial derivatives", messageCount: 7 }));
|
||||
assert.ok(html.includes("Partial derivatives"));
|
||||
assert.match(html, /7 messages · \d{1,2}\/\d{1,2}\/\d{4}/);
|
||||
});
|
||||
|
||||
it("active row is visually marked", () => {
|
||||
const html = render(item({}), true);
|
||||
assert.ok(html.includes("font-medium"), "active emphasis");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user