Chat mode, phase 2: local history, answer actions, motion polish

- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod
  validated with the same schema backup/sync use, capped at 100 x 300).
  Settled turns persist (question always; answer when produced, partial
  stops included); ?c=<id> keeps reloads on the conversation via
  history.replaceState (no router round-trip mid-chat).
- History UI: desktop column, phone sheet (spring, backdrop, Escape),
  auto titles from the first question, inline rename, delete with an
  Undo toast, diacritic-folding search over titles and message text.
  Scope and model are remembered per conversation; history is readable
  offline.
- Assistant actions: Turn into flashcards (New deck with the answer as
  notes, landing on the options step - deck seed gained step) and Save
  to notes (appends to the single scoped deck, trimmed at 40k).
- Chats travel in the backup export (preview counts them) and in the
  encrypted sync payload; still never the key.
- Motion from lib/motion.ts: message fade-up, sheet springs for the
  model/context/history sheets, 150ms composer height transition, all
  skipped under reduced motion.
- Tests: chat suite 63 -> 89 checks (history, reload, rename/search/
  delete/undo, actions, export, two-device sync round-trip, offline,
  desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33.

Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the
default text model on numbers; defaults unchanged.
This commit is contained in:
Martin Dvořák 2026-10-11 19:03:48 +00:00
commit 5ca0de920c
19 changed files with 1107 additions and 113 deletions

View file

@ -1,9 +1,11 @@
"use client";
import { useEffect } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { useIsPhone } from "@/lib/use-phone";
import { deckHasNotes } from "@/lib/chat-context";
import type { ChatScope } from "@/lib/chat";
@ -31,6 +33,7 @@ export function ContextPicker({
}) {
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
useEffect(() => {
if (!open) return;
@ -41,8 +44,6 @@ export function ContextPicker({
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
const choose = (next: ChatScope) => {
@ -171,29 +172,56 @@ export function ContextPicker({
if (isPhone) {
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div>
</div>
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45"
aria-label={t("common.close")}
onClick={onClose}
/>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet}
className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2"
>
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}
return (
<div
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog"
aria-label={t("chat.scope.title")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")}
</h2>
{body}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div>
<AnimatePresence>
{open ? (
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog"
aria-label={t("chat.scope.title")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")}
</h2>
{body}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div>
) : null}
</AnimatePresence>
);
}