practice entry points: sidebar filter, practice draft, graph picker

- Sidebar: the three-way kind filter (Teaching/All/Practice, persisted
  in localStorage) above the search; two new-session doors (Teaching →
  /chat/new, Practice → /chat/new?mode=practice); practice rows carry
  the dumbbell badge and 'Practicing…' busy meta.
- Draft: ?mode=practice swaps the copy (title, empty state, 'What do
  you want to practice?') and startDraftSession posts kind:'practice'.
- Graph entry (home composer): teach|practice switch on the stats row's
  left edge (StatsBar leadingSlot); practice mode shows the propositions
  chip that opens the chain table as a picker (its floating bar becomes
  'Practice these' — no delete fence); the picks seed the opening prompt
  ('Propositions from the map:…') while the title stays the typed text.
This commit is contained in:
amosblomqvist
2026-10-04 22:01:42 +03:00
parent 59bf5bf219
commit e76e42f764
9 changed files with 290 additions and 33 deletions
+73 -16
View File
@@ -6,7 +6,7 @@ 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 { DumbbellIcon, 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";
@@ -227,7 +227,7 @@ export function timeAgo(iso: string, now = new Date()): string {
* 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…";
if (chat.busy) return (chat.kind ?? "teach") === "practice" ? "Practicing…" : "Teacher is working…";
return timeAgo(chat.updatedAt);
}
@@ -265,6 +265,9 @@ export function SessionRow({
)}
>
<SessionStatus busy={chat.busy} />
{(chat.kind ?? "teach") === "practice" && (
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
)}
<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);
@@ -328,10 +331,25 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
);
}
/** 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";
function SessionsSidebar() {
const { open, toggle } = useSidebar();
const [chats, setChats] = useState<ChatListItem[] | null>(null);
const [query, setQuery] = useState("");
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);
}, []);
const pathname = usePathname();
const router = useRouter();
const { open: openSettings } = useSettingsDialog();
@@ -346,10 +364,14 @@ function SessionsSidebar() {
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]);
let list = chats;
if (list && kindFilter !== "all") {
list = list.filter((c) => (c.kind ?? "teach") === kindFilter);
}
if (!list) return list;
if (!q) return list;
return list.filter((c) => c.title.toLowerCase().includes(q));
}, [chats, query, kindFilter]);
const refreshInFlightRef = useRef(false);
const refresh = useCallback(async () => {
@@ -644,16 +666,51 @@ function SessionsSidebar() {
</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>
{/* Two doors (practice sessions, owner 2026-10-04): teaching
keeps the original draft route; practice carries its mode. */}
<div className="mx-3 mb-1 flex gap-1.5">
<button
type="button"
data-testid="new-session"
onClick={() => void newSession()}
className="flex flex-1 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" />
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")}
className="flex flex-1 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"
>
<DumbbellIcon className="size-3" />
Practice
<span className="sr-only">create a new practice session</span>
</button>
</div>
{/* The three-way filter (owner, 2026-10-04): Teaching / All /
Practice, quiet segmented, persisted client-side. */}
<div className="mx-3 mb-1.5 flex items-center gap-0.5 rounded-md border border-border/60 bg-background p-0.5" data-testid="session-kind-filter">
{(["teach", "all", "practice"] 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>
<div className="mx-3 mb-1 flex items-center gap-2">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>