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
205
components/chat/HistoryPanel.tsx
Normal file
205
components/chat/HistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CheckCircleIcon, PencilIcon, PlusIcon, XIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { conversationMatches } from "@/lib/chat";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
|
||||
type HistoryProps = {
|
||||
conversations: ChatConversation[];
|
||||
currentId: string | null;
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (conversation: ChatConversation) => void;
|
||||
onNew: () => void;
|
||||
};
|
||||
|
||||
/** The conversation list: a column on desktop, a sheet on phones. */
|
||||
export function ChatHistoryList({
|
||||
conversations,
|
||||
currentId,
|
||||
query,
|
||||
onQueryChange,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNew,
|
||||
}: HistoryProps) {
|
||||
const { t } = useI18n();
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const visible = conversations.filter((conversation) =>
|
||||
conversationMatches(conversation.title, conversation.messages, query),
|
||||
);
|
||||
|
||||
const startRename = (conversation: ChatConversation) => {
|
||||
setRenaming(conversation.id);
|
||||
setDraft(conversation.title);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (renaming) onRename(renaming, draft);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={t("chat.searchChats")}
|
||||
aria-label={t("chat.searchChats")}
|
||||
className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={onNew} aria-label={t("chat.new")}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.new")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-2 py-8 text-center text-[12.5px] text-[var(--text-subtle)]">
|
||||
{query ? t("chat.noResults") : t("chat.noHistory")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="thin-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain px-1 pb-2">
|
||||
{visible.map((conversation) => {
|
||||
const active = conversation.id === currentId;
|
||||
return (
|
||||
<li key={conversation.id}>
|
||||
{renaming === conversation.id ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
className="flex items-center gap-1 px-1 py-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
aria-label={t("common.rename")}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={t("common.save")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
|
||||
>
|
||||
<CheckCircleIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(conversation.id)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 rounded-xl px-2.5 py-2 text-left transition",
|
||||
active ? "bg-[var(--nav-active-bg)]" : "hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
|
||||
{conversation.title || t("chat.untitled")}
|
||||
</span>
|
||||
<span className="block text-[11px] text-[var(--text-subtle)]">
|
||||
{formatDate(conversation.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(conversation)}
|
||||
aria-label={t("common.rename")}
|
||||
title={t("common.rename")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(conversation)}
|
||||
aria-label={t("chat.deleteChat")}
|
||||
title={t("chat.deleteChat")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Phone sheet wrapper around the list (drag/backdrop close, spring). */
|
||||
export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open: boolean; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.history")}>
|
||||
<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 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2>
|
||||
<ChatHistoryList {...props} />
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue