freezmo/components/TabBar.tsx
Martin Dvorak 756a6257a2 Chat mode, phase 1: grounded streaming chat over your notes
Entry points (no sixth tab): Ask in the Library header (desktop button,
phone icon), Ask about this deck in the deck menu, Ask a question on
study/homework results (compact result travels as context), and a Chat
row in the desktop sidebar. Phones get a full-screen route with back +
New chat; the tab bar steps aside.

- Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against
  the question + recent turns, top chunks under ~7k tokens, each
  labelled with deck + section. Notes come from the deck's stored
  sourceText (already kept for Regenerate); decks without it fall back
  to generated content and the UI says so.
- Stateless server: /api/chat validates against the CHAT_MODELS
  allowlist, trims the history to ~6k tokens, caps context at ~9k and
  streams token/thinking/done/error NDJSON. /api/chat/extract reduces
  attachments to text through the existing vision step.
- UI: user bubbles + full-width assistant text, streaming with stop,
  auto-scroll that pauses with a jump-to-bottom button, safe markdown
  (React nodes, no HTML injection; KaTeX dynamically imported only when
  a message has math), Thinking row for reasoning, copy/regenerate/
  edit-and-resend, model + context pickers (last model remembered),
  composer with visualViewport keyboard handling, safe-area, 16px
  textarea, Enter-to-send on desktop.
- Usage-limit failures keep the draft and show the shared message with
  Settings/Ollama actions; stopping maps the reader abort to a clean
  stopped state; offline disables sending with a reason.
- Settings: Keep notes text for chat toggle; deck menu: Delete notes
  text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README.
- Tests: chat suite (63 checks, stub Ollama) wired into run-all.
  Full set: 12/12 suites, 487 checks. SW -> freezmo-v32.

Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the
same notes (latency, repair rounds, activity types) so the gpt-oss vs
gemma default can be decided on numbers; defaults unchanged.
2026-10-11 18:32:11 +00:00

134 lines
4.6 KiB
TypeScript

"use client";
import { motion, useReducedMotion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CompassIcon, FlameIcon, GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { springSnappy } from "@/lib/motion";
import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
/**
* iOS-style bottom tab bar, phones only: Library · Progress · New deck (the
* prominent centre button) · Exam mode · Settings. The active item sits on a
* sliding pill (shared `layoutId`) and its icon gives a small bounce when you
* land on it; re-tapping the active tab scrolls back to the top.
*
* Labels use the short `tab.*` keys so they fit on one line at 360px in both
* languages — no ellipsis, no wrapping.
*/
export function TabBar() {
const pathname = usePathname();
const { t } = useI18n();
const reduceMotion = useReducedMotion();
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) and
chat keeps its own composer — the flow is deliberately focused, so the tab
bar steps aside there. */
const inWizard = pathname === "/create" || pathname === "/homework" || pathname === "/chat";
const item =
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";
const active = "text-[var(--text)]";
const idle = "text-[var(--text-subtle)]";
/** The pill glides between items; hidden under reduced motion. */
const pill = (isActive: boolean) =>
isActive && !reduceMotion ? (
<motion.span
aria-hidden
layoutId="tabbar-active"
transition={springSnappy}
className="absolute inset-x-2 inset-y-0.5 rounded-2xl bg-[var(--nav-active-bg)] shadow-[inset_0_0_0_1px_var(--nav-active-border)]"
/>
) : null;
/** Small icon bounce on the item you just selected. */
const icon = (children: React.ReactNode, isActive: boolean, key: string) => (
<motion.span
key={`${key}-${isActive ? pathname : "idle"}`}
animate={isActive && !reduceMotion ? { scale: [1, 1.16, 1] } : { scale: 1 }}
transition={springSnappy}
className="relative z-10 grid place-items-center"
>
{children}
</motion.span>
);
const label = (text: string, isActive: boolean) => (
<span className={cn("relative z-10 whitespace-nowrap", isActive ? active : idle)}>{text}</span>
);
const isActive = (key: "library" | "progress" | "create" | "exam") => {
if (key === "library") return pathname === "/";
if (key === "progress") return pathname === "/stats";
// The homework wizard is the other half of the New deck screen.
if (key === "create") return pathname === "/create" || pathname === "/homework";
return pathname === "/exam";
};
const link = (
key: "library" | "progress" | "create" | "exam",
href: string,
labelText: string,
iconNode: React.ReactNode,
) => {
const current = isActive(key);
return (
<Link
key={key}
href={href}
aria-current={current ? "page" : undefined}
className={cn(item, current ? active : idle)}
onClick={(event) => {
haptic("tap");
// Re-tapping the active tab scrolls back to the top.
if (current) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(current)}
{icon(iconNode, current, key)}
{label(labelText, current)}
</Link>
);
};
if (inWizard) return null;
return (
<nav
aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
>
{link("library", "/", t("tab.library"), <HomeIcon className="size-6" />)}
{link("progress", "/stats", t("tab.progress"), <FlameIcon className="size-6" />)}
{link(
"create",
"/create",
t("tab.new"),
<span className="grad-ice grid size-7 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>,
)}
{link("exam", "/exam", t("tab.exam"), <CompassIcon className="size-6" />)}
<button
type="button"
aria-haspopup="dialog"
onClick={() => {
haptic("tap");
openSettings();
}}
className={cn(item, idle)}
>
{icon(<GearIcon className="size-6" />, false, "settings")}
{label(t("tab.settings"), false)}
</button>
</nav>
);
}