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:
parent
756a6257a2
commit
5ca0de920c
19 changed files with 1107 additions and 113 deletions
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatModelChoice } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -28,6 +30,7 @@ export function ModelPicker({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -38,8 +41,6 @@ export function ModelPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const badge = (model: ChatModelChoice) =>
|
||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
|
|
@ -87,29 +88,56 @@ export function ModelPicker({
|
|||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<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-[80dvh] 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.modelTitle")}</h2>
|
||||
{rows}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<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-[80dvh] 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.modelTitle")}</h2>
|
||||
{rows}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</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 right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue