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

@ -711,6 +711,14 @@ have a step explained without leaving the app.
allowlist and never accepts a model name from the client. allowlist and never accepts a model name from the client.
- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first - **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
(plain text), so any chat model works — text-only included. (plain text), so any chat model works — text-only included.
- **History.** Conversations live in a local IndexedDB store: a list on the left on desktop, a
sheet on phones, with auto titles from your first question, rename, delete (with Undo) and a
search across titles and message text. Conversations remember their scope and model, reopen from
`?c=<id>` (so a reload continues where you left off), stay readable offline, and are included in
the backup export and the encrypted sync — still never the key.
- **From an answer.** Every answer can become a deck (**Turn into flashcards** opens New deck with
the answer as notes, on the options step) or be appended to the scoped deck's notes (**Save to
notes**, only offered when exactly one deck is in scope).
- **Streaming and controls.** Tokens stream with a caret and a stop button; auto-scroll pauses when - **Streaming and controls.** Tokens stream with a caret and a stop button; auto-scroll pauses when
you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables, you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables,
code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message
@ -1136,7 +1144,7 @@ Things I chose while building, with the reasoning:
limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or
VAPID keys start their own `next start` instance on a spare port, verify the port is free and VAPID keys start their own `next start` instance on a spare port, verify the port is free and
kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites, kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites,
487 checks. 513 checks.
46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy 46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy
orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the
@ -1183,6 +1191,24 @@ Things I chose while building, with the reasoning:
points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate, points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate,
offline, attachments and reduced motion). offline, attachments and reduced motion).
48. **Chat history is local, bounded and part of the backup/sync payload.** Conversations live in
their own versioned IndexedDB (`freezmo-chat` v1, store `chats`), validated with the same zod
schema the backup and sync use (`chatConversationSchema`), capped at 100 conversations × 300
messages, with the in-memory mirror the other stores use. Every settled turn is persisted (the
question always, the answer when one was produced — partial answers from a stop included); the
URL carries `?c=<id>` so a reload continues the conversation, and that URL tweak uses
`history.replaceState` so it never triggers a router round-trip mid-chat. The list (desktop
column, phone sheet) has auto titles from the first question, inline rename, delete with an Undo
toast, and diacritic-folding search over titles and message text. Chats ride along in the backup
file (the import preview counts them) and in the encrypted sync payload — still never the key.
Assistant answers also offer **Turn into flashcards** (opens New deck with the answer as notes,
landing on the options step) and **Save to notes** (appends to the single scoped deck, trimming
at 40k characters). Motion comes from `lib/motion.ts`: message fade-up, sheet springs, a 150 ms
composer height transition — all skipped under reduced motion. Verified by the chat suite (89
checks: everything from phase 1 plus history persistence, reload, rename/search/delete/undo,
both actions, the export payload, a two-device sync round-trip of a chat, offline readability
and the desktop panel).
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck 36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);

View file

@ -92,6 +92,7 @@ export function DataSection() {
decks: pending.preview.decks.added + pending.preview.decks.replaced, decks: pending.preview.decks.added + pending.preview.decks.replaced,
homework: pending.preview.homework.added + pending.preview.homework.replaced, homework: pending.preview.homework.added + pending.preview.homework.replaced,
folders: pending.preview.folders.added + pending.preview.folders.replaced, folders: pending.preview.folders.added + pending.preview.folders.replaced,
chats: pending.preview.chats.added + pending.preview.chats.replaced,
})} })}
</p> </p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p> <p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>

View file

@ -328,6 +328,7 @@ export function UploadScreen() {
setText(seed.text); setText(seed.text);
if (seed.difficulty) setDifficulty(seed.difficulty); if (seed.difficulty) setDifficulty(seed.difficulty);
if (seed.count) setCount(seed.count); if (seed.count) setCount(seed.count);
if (seed.step && seed.step > 1) setStep(seed.step);
}, []); }, []);
/* Persist the draft while the user works (debounced, best-effort). */ /* Persist the draft while the user works (debounced, best-effort). */

View file

@ -7,9 +7,10 @@ import Link from "next/link";
import { Composer, type PendingAttachment } from "./Composer"; import { Composer, type PendingAttachment } from "./Composer";
import { ContextPicker } from "./ContextPicker"; import { ContextPicker } from "./ContextPicker";
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow"; import { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker"; import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons"; import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
@ -17,24 +18,30 @@ import { ApiError } from "@/lib/client-api";
import { extractChatAttachment, streamChat } from "@/lib/chat-client"; import { extractChatAttachment, streamChat } from "@/lib/chat-client";
import { buildChatContext, deckHasNotes } from "@/lib/chat-context"; import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed"; import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
import { import {
chatMessageId, chatMessageId,
readStoredChatModel, readStoredChatModel,
storeChatModel, storeChatModel,
titleFromMessage,
type ChatAttachment, type ChatAttachment,
type ChatMessage, type ChatMessage,
type ChatModelChoice, type ChatModelChoice,
type ChatScope, type ChatScope,
} from "@/lib/chat"; } from "@/lib/chat";
import { setDeckSeed } from "@/lib/deck-seed";
import { extractDocument } from "@/lib/documents"; import { extractDocument } from "@/lib/documents";
import { folderStore } from "@/lib/folders"; import { folderStore } from "@/lib/folders";
import { isImageFile, prepareImage } from "@/lib/image"; import { isImageFile, prepareImage } from "@/lib/image";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import { showToast } from "@/lib/toast"; import { showToast } from "@/lib/toast";
import { deckStore } from "@/lib/storage"; import { deckStore, upsertDeck } from "@/lib/storage";
import { settingsStore } from "@/lib/settings"; import { settingsStore } from "@/lib/settings";
import type { ChatConversation } from "@/lib/schemas";
import { useHealth } from "@/lib/use-health"; import { useHealth } from "@/lib/use-health";
import { useOnline } from "@/lib/use-online"; import { useOnline } from "@/lib/use-online";
import { motion } from "framer-motion";
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string }; type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
@ -71,6 +78,16 @@ export function ChatScreen() {
const [scopeOpen, setScopeOpen] = useState(false); const [scopeOpen, setScopeOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<{ message: string; kind?: string } | null>(null); const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
const [query, setQuery] = useState("");
const conversations = useSyncExternalStore(
chatHistoryStore.subscribe,
chatHistoryStore.getSnapshot,
chatHistoryStore.getServerSnapshot,
);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -89,6 +106,33 @@ export function ChatScreen() {
setModel(readStoredChatModel() ?? ""); setModel(readStoredChatModel() ?? "");
}, []); }, []);
/* History: hydrate once, then open ?c=<id> conversations. */
useEffect(() => {
void ensureChatsHydrated();
}, []);
const loadedRef = useRef<string | null>(null);
const loadConversation = useCallback((found: ChatConversation) => {
setConversationId(found.id);
setTitle(found.title);
setMessages(conversationMessages(found));
setScope(found.scope);
setSeed(null);
setAttachments([]);
setDraft("");
setError(null);
if (found.model) setModel(found.model);
loadedRef.current = found.id;
}, []);
const conversationParam = search.get("c");
useEffect(() => {
if (!conversationParam || loadedRef.current === conversationParam) return;
const found = getConversation(conversationParam);
if (!found) return; // hydration may still be running; re-runs when it lands
loadConversation(found);
}, [conversationParam, conversations, loadConversation]);
/* Keyboard: keep the composer above the on-screen keyboard. */ /* Keyboard: keep the composer above the on-screen keyboard. */
useEffect(() => { useEffect(() => {
const viewport = window.visualViewport; const viewport = window.visualViewport;
@ -191,6 +235,33 @@ export function ChatScreen() {
return chunks; return chunks;
}; };
const persist = useCallback(
(nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
const id = conversationId ?? chatMessageId("chat");
if (!conversationId) {
setConversationId(id);
// Keep the URL in step so a reload continues this conversation.
// history.replaceState avoids a router round-trip for the same page.
window.history.replaceState(null, "", `/chat?c=${id}`);
}
const firstUser = nextMessages.find((message) => message.role === "user");
const derived = title || (firstUser ? titleFromMessage(firstUser.content) : "");
const nextTitle = options.title ?? derived;
const existing = getConversation(id);
saveConversation({
id,
title: nextTitle,
messages: nextMessages,
scope: options.scope ?? scope,
model: options.model ?? (activeModel || undefined),
createdAt: existing?.createdAt ?? Date.now(),
updatedAt: Date.now(),
});
if (nextTitle && nextTitle !== title) setTitle(nextTitle);
},
[conversationId, title, scope, activeModel],
);
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => { const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
const lastUser = history[history.length - 1]; const lastUser = history[history.length - 1];
const context = buildChatContext({ const context = buildChatContext({
@ -211,6 +282,10 @@ export function ChatScreen() {
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
let produced = false; let produced = false;
let finalContent = "";
let finalThinking = "";
let finalModel: string | undefined;
let failure: { message: string; kind?: string } | null = null;
try { try {
await streamChat({ await streamChat({
@ -220,6 +295,7 @@ export function ChatScreen() {
signal: controller.signal, signal: controller.signal,
onToken: (text) => { onToken: (text) => {
produced = true; produced = true;
finalContent += text;
setMessages((previous) => setMessages((previous) =>
previous.map((message) => previous.map((message) =>
message.id === assistantId ? { ...message, content: message.content + text } : message, message.id === assistantId ? { ...message, content: message.content + text } : message,
@ -227,6 +303,7 @@ export function ChatScreen() {
); );
}, },
onThinking: (text) => { onThinking: (text) => {
finalThinking += text;
setMessages((previous) => setMessages((previous) =>
previous.map((message) => previous.map((message) =>
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message, message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
@ -234,6 +311,7 @@ export function ChatScreen() {
); );
}, },
onDone: (modelName) => { onDone: (modelName) => {
finalModel = modelName;
setMessages((previous) => setMessages((previous) =>
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)), previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
); );
@ -244,7 +322,8 @@ export function ChatScreen() {
if (apiError.kind === "aborted") { if (apiError.kind === "aborted") {
// Stopped by the user: keep whatever streamed in. // Stopped by the user: keep whatever streamed in.
} else { } else {
setError({ message: apiError.message, kind: apiError.kind }); failure = { message: apiError.message, kind: apiError.kind };
setError(failure);
if (!produced) { if (!produced) {
// Nothing arrived: take the placeholder away and give the text back. // Nothing arrived: take the placeholder away and give the text back.
setMessages(history); setMessages(history);
@ -253,7 +332,7 @@ export function ChatScreen() {
setMessages((previous) => setMessages((previous) =>
previous.map((message) => previous.map((message) =>
message.id === assistantId message.id === assistantId
? { ...message, error: { message: apiError.message, kind: apiError.kind } } ? { ...message, error: failure ?? undefined }
: message, : message,
), ),
); );
@ -262,6 +341,21 @@ export function ChatScreen() {
} finally { } finally {
abortRef.current = null; abortRef.current = null;
setBusy(false); setBusy(false);
// Persist the settled turn: the question always, the answer when one
// was produced (partial answers from a stop are kept too).
const assistant: ChatMessage | null = produced
? {
id: assistantId,
role: "assistant",
content: finalContent,
thinking: finalThinking || undefined,
model: finalModel,
sources,
at: Date.now(),
...(failure ? { error: failure } : {}),
}
: null;
persist(assistant ? [...history, assistant] : history);
} }
}; };
@ -311,10 +405,70 @@ export function ChatScreen() {
const newChat = () => { const newChat = () => {
abortRef.current?.abort(); abortRef.current?.abort();
loadedRef.current = null;
setConversationId(null);
setTitle("");
setMessages([]); setMessages([]);
setSeed(null); setSeed(null);
setAttachments([]); setAttachments([]);
setDraft(""); setDraft("");
setError(null);
setHistoryOpen(false);
window.history.replaceState(null, "", "/chat");
};
const openConversation = (id: string) => {
abortRef.current?.abort();
setHistoryOpen(false);
const found = getConversation(id);
if (found) loadConversation(found);
router.push(`/chat?c=${id}`);
};
const handleDeleteConversation = (conversation: ChatConversation) => {
deleteConversation(conversation.id);
if (conversation.id === conversationId) {
setConversationId(null);
setTitle("");
setMessages([]);
}
showToast({
message: t("chat.deletedChat"),
actionLabel: t("toast.undo"),
onAction: () => saveConversation(conversation),
});
};
/* The single scoped deck is the target for "Save to notes". */
const noteTargetDeck = useMemo(() => {
if (scope.kind === "decks" && scope.deckIds.length === 1) {
return decks.find((deck) => deck.id === scope.deckIds[0]);
}
if (seed?.deckId) return decks.find((deck) => deck.id === seed.deckId);
return undefined;
}, [scope, seed, decks]);
const saveToNotes = (message: ChatMessage) => {
if (!noteTargetDeck) return;
const current = noteTargetDeck.sourceText?.trim() ?? "";
const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`;
let next = `${current}${addition}`;
let trimmed = false;
if (next.length > 40_000) {
next = next.slice(-40_000);
trimmed = true;
}
upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() });
showToast({
message: trimmed
? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title })
: t("chat.savedToNotes", { deck: noteTargetDeck.title }),
});
};
const toFlashcards = (message: ChatMessage) => {
setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
router.push("/create");
}; };
const handleAttach = async (file: File) => { const handleAttach = async (file: File) => {
@ -346,7 +500,25 @@ export function ChatScreen() {
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id; const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
return ( return (
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}> <div className="flex h-full min-h-0 w-full">
{/* Desktop: the conversation list lives in its own column. */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2 lg:flex">
<h2 className="px-1 pb-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.history")}
</h2>
<ChatHistoryList
conversations={conversations}
currentId={conversationId}
query={query}
onQueryChange={setQuery}
onOpen={openConversation}
onRename={renameConversation}
onDelete={handleDeleteConversation}
onNew={newChat}
/>
</aside>
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4"> <header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
<button <button
type="button" type="button"
@ -358,12 +530,21 @@ export function ChatScreen() {
<ChevronLeftIcon className="size-5" /> <ChevronLeftIcon className="size-5" />
</button> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{t("chat.title")}</h1> <h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{title || t("chat.title")}</h1>
<p className="truncate text-[11.5px] text-[var(--text-subtle)]"> <p className="truncate text-[11.5px] text-[var(--text-subtle)]">
{scopeText} {scopeText}
{seed ? ` · ${seed.label}` : ""} {seed ? ` · ${seed.label}` : ""}
</p> </p>
</div> </div>
<button
type="button"
onClick={() => setHistoryOpen(true)}
aria-label={t("chat.history")}
title={t("chat.history")}
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<HistoryIcon className="size-5" />
</button>
<ThemeToggle className="hidden lg:grid" /> <ThemeToggle className="hidden lg:grid" />
<button <button
type="button" type="button"
@ -418,8 +599,13 @@ export function ChatScreen() {
</div> </div>
) : null} ) : null}
{messages.map((message) => ( {messages.map((message) => (
<MessageRow <motion.div
key={message.id} key={message.id}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduceMotion ? { duration: 0 } : springGentle}
>
<MessageRow
message={message} message={message}
streaming={busy && message.id === lastAssistantId} streaming={busy && message.id === lastAssistantId}
isLastAssistant={message.id === lastAssistantId} isLastAssistant={message.id === lastAssistantId}
@ -428,7 +614,10 @@ export function ChatScreen() {
onRegenerate={regenerate} onRegenerate={regenerate}
onEdit={editLast} onEdit={editLast}
onRetry={message.id === lastFailedAssistant ? retryLast : undefined} onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
onToFlashcards={() => toFlashcards(message)}
onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined}
/> />
</motion.div>
))} ))}
</> </>
)} )}
@ -495,13 +684,17 @@ export function ChatScreen() {
onChange={(next) => { onChange={(next) => {
setModel(next); setModel(next);
storeChatModel(next); storeChatModel(next);
if (messages.length > 0) persist(messages, { model: next });
}} }}
/> />
<ContextPicker <ContextPicker
open={scopeOpen} open={scopeOpen}
onClose={() => setScopeOpen(false)} onClose={() => setScopeOpen(false)}
scope={scope} scope={scope}
onChange={setScope} onChange={(next) => {
setScope(next);
if (messages.length > 0) persist(messages, { scope: next });
}}
decks={decks} decks={decks}
folders={folders} folders={folders}
/> />
@ -516,5 +709,19 @@ export function ChatScreen() {
</p> </p>
) : null} ) : null}
</div> </div>
<HistorySheet
open={historyOpen}
onClose={() => setHistoryOpen(false)}
conversations={conversations}
currentId={conversationId}
query={query}
onQueryChange={setQuery}
onOpen={openConversation}
onRename={renameConversation}
onDelete={handleDeleteConversation}
onNew={newChat}
/>
</div>
); );
} }

View file

@ -122,7 +122,7 @@ export function Composer({
rows={1} rows={1}
placeholder={t("chat.placeholder")} placeholder={t("chat.placeholder")}
aria-label={t("chat.placeholder")} aria-label={t("chat.placeholder")}
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60" className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
/> />
{busy ? ( {busy ? (

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons"; import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import { deckHasNotes } from "@/lib/chat-context"; import { deckHasNotes } from "@/lib/chat-context";
import type { ChatScope } from "@/lib/chat"; import type { ChatScope } from "@/lib/chat";
@ -31,6 +33,7 @@ export function ContextPicker({
}) { }) {
const { t, plural } = useI18n(); const { t, plural } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -41,8 +44,6 @@ export function ContextPicker({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
if (!open) return null;
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>(); const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
const choose = (next: ChatScope) => { const choose = (next: ChatScope) => {
@ -171,20 +172,45 @@ export function ContextPicker({
if (isPhone) { if (isPhone) {
return ( return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}> <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} /> <motion.button
<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"> 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 /> <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> <h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body} {body}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p> <p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div>
</div> </div>
</div> ) : null}
</AnimatePresence>
); );
} }
return ( return (
<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" className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog" role="dialog"
aria-label={t("chat.scope.title")} aria-label={t("chat.scope.title")}
@ -194,6 +220,8 @@ export function ContextPicker({
</h2> </h2>
{body} {body}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p> <p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</div> </motion.div>
) : null}
</AnimatePresence>
); );
} }

View 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>
);
}

View file

@ -7,7 +7,7 @@ import { Markdown } from "./Markdown";
import { ThinkingRow } from "./ThinkingRow"; import { ThinkingRow } from "./ThinkingRow";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
import { PencilIcon, RefreshIcon } from "@/components/icons"; import { PencilIcon, PlusIcon, RefreshIcon, SparklesIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { ChatMessage } from "@/lib/chat"; import type { ChatMessage } from "@/lib/chat";
@ -24,6 +24,8 @@ export function MessageRow({
onRegenerate, onRegenerate,
onEdit, onEdit,
onRetry, onRetry,
onToFlashcards,
onSaveNotes,
}: { }: {
message: ChatMessage; message: ChatMessage;
streaming?: boolean; streaming?: boolean;
@ -33,6 +35,8 @@ export function MessageRow({
onRegenerate?: () => void; onRegenerate?: () => void;
onEdit?: () => void; onEdit?: () => void;
onRetry?: () => void; onRetry?: () => void;
onToFlashcards?: () => void;
onSaveNotes?: () => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@ -108,6 +112,26 @@ export function MessageRow({
{t("chat.regenerate")} {t("chat.regenerate")}
</button> </button>
) : null} ) : null}
{onToFlashcards ? (
<button
type="button"
onClick={onToFlashcards}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<SparklesIcon className="size-3.5" />
{t("chat.toFlashcards")}
</button>
) : null}
{onSaveNotes ? (
<button
type="button"
onClick={onSaveNotes}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
{t("chat.saveToNotes")}
</button>
) : null}
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null} {message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
</div> </div>
) : null} ) : null}

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons"; import { CheckCircleIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import type { ChatModelChoice } from "@/lib/chat"; import type { ChatModelChoice } from "@/lib/chat";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -28,6 +30,7 @@ export function ModelPicker({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -38,8 +41,6 @@ export function ModelPicker({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
if (!open) return null;
const badge = (model: ChatModelChoice) => const badge = (model: ChatModelChoice) =>
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
@ -87,20 +88,45 @@ export function ModelPicker({
if (isPhone) { if (isPhone) {
return ( return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}> <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} /> <motion.button
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2"> 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 /> <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> <h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
{rows} {rows}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p> <p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</motion.div>
</div> </div>
</div> ) : null}
</AnimatePresence>
); );
} }
return ( return (
<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" className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
role="dialog" role="dialog"
aria-label={t("chat.modelTitle")} aria-label={t("chat.modelTitle")}
@ -110,6 +136,8 @@ export function ModelPicker({
</h2> </h2>
{rows} {rows}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p> <p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
</div> </motion.div>
) : null}
</AnimatePresence>
); );
} }

View file

@ -12,11 +12,12 @@
import { z } from "zod"; import { z } from "zod";
import { applyTheme } from "@/components/ThemeToggle"; import { applyTheme } from "@/components/ThemeToggle";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { replaceFolders, folderStore } from "./folders"; import { replaceFolders, folderStore } from "./folders";
import { homeworkStore, replaceHomework } from "./homework"; import { homeworkStore, replaceHomework } from "./homework";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { setLocale, t } from "./i18n"; import { setLocale, t } from "./i18n";
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema } from "./schemas"; import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
import { saveSettings, settingsStore } from "./settings"; import { saveSettings, settingsStore } from "./settings";
import { deckStore, replaceDecks } from "./storage"; import { deckStore, replaceDecks } from "./storage";
@ -36,6 +37,8 @@ export const backupDataSchema = z.object({
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */ /** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
history: z.array(answerRecordSchema).max(20_000).optional(), history: z.array(answerRecordSchema).max(20_000).optional(),
srs: z.array(srsRecordSchema).max(50_000).optional(), srs: z.array(srsRecordSchema).max(50_000).optional(),
/** Chat conversations (IndexedDB). Optional for old backups. */
chats: z.array(chatConversationSchema).max(100).optional(),
}); });
export const backupSchema = z.object({ export const backupSchema = z.object({
@ -54,6 +57,7 @@ export type BackupFile = z.infer<typeof backupSchema>;
export async function buildBackup(): Promise<BackupFile> { export async function buildBackup(): Promise<BackupFile> {
await ensureLearningHydrated(); await ensureLearningHydrated();
await ensureChatsHydrated();
return { return {
app: "freezmo", app: "freezmo",
kind: "backup", kind: "backup",
@ -65,6 +69,7 @@ export async function buildBackup(): Promise<BackupFile> {
homework: homeworkStore.getSnapshot(), homework: homeworkStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(), history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(), srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
settings: { settings: {
haptics: settingsStore.getSnapshot().haptics, haptics: settingsStore.getSnapshot().haptics,
dailyGoal: settingsStore.getSnapshot().dailyGoal, dailyGoal: settingsStore.getSnapshot().dailyGoal,
@ -138,6 +143,7 @@ export type ImportPreview = {
decks: ImportCounts; decks: ImportCounts;
folders: ImportCounts; folders: ImportCounts;
homework: ImportCounts; homework: ImportCounts;
chats: ImportCounts;
}; };
export function previewImport(backup: BackupFile): ImportPreview { export function previewImport(backup: BackupFile): ImportPreview {
@ -145,6 +151,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
decks: countMerge(deckStore.getSnapshot(), backup.data.decks), decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
folders: countMerge(folderStore.getSnapshot(), backup.data.folders), folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework), homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []),
}; };
} }
@ -184,6 +191,13 @@ export async function applyImport(
await replaceLearning(answers, [...srsByKey.values()]); await replaceLearning(answers, [...srsByKey.values()]);
} }
// Chat conversations: merge by id (newest wins) or replace wholesale.
await ensureChatsHydrated();
const incomingChats = data.chats ?? [];
if (incomingChats.length > 0) {
replaceChats(mode === "replace" ? incomingChats : mergeById(chatHistoryStore.getSnapshot(), incomingChats));
}
const settings = data.settings; const settings = data.settings;
if (settings) { if (settings) {
if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics }); if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics });

195
lib/chat-store.ts Normal file
View file

@ -0,0 +1,195 @@
"use client";
/**
* Chat conversations, kept in IndexedDB so history survives reloads and the
* tab can be closed mid-conversation. The in-memory mirror makes reads
* synchronous for React (`useSyncExternalStore`); writes update the mirror
* immediately and persist asynchronously.
*
* Versioned DB `freezmo-chat` v1 with one store:
* chats keyPath id (index: updatedAt)
*
* Every stored record is validated with the shared zod schema, and the store
* is capped (oldest conversations first) so a long-lived browser can't grow
* without bound. Chats are part of the export/backup and the encrypted sync
* payload — never the API key.
*/
import { chatConversationSchema, type ChatConversation, type ChatMessage } from "./schemas";
import { markSyncDirty } from "./sync-state";
const DB_NAME = "freezmo-chat";
const DB_VERSION = 1;
const CHATS = "chats";
/** Caps: a personal app, but still bounded. */
export const MAX_CONVERSATIONS = 100;
export const MAX_MESSAGES_PER_CHAT = 300;
const EMPTY: ChatConversation[] = [];
type Listener = () => void;
let conversations: ChatConversation[] = [];
let cached: ChatConversation[] = EMPTY;
const listeners = new Set<Listener>();
let hydrated = false;
let hydration: Promise<void> | null = null;
function notify(): void {
cached = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
listeners.forEach((listener) => listener());
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(CHATS)) {
const store = db.createObjectStore(CHATS, { keyPath: "id" });
store.createIndex("updatedAt", "updatedAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function withDb<T>(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise<T> {
const db = await openDb();
try {
return run(db);
} finally {
window.setTimeout(() => db.close(), 1000);
}
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function persist(conversation: ChatConversation): void {
void withDb("readwrite", (db) => {
const transaction = db.transaction(CHATS, "readwrite");
transaction.objectStore(CHATS).put(conversation);
}).catch((error) => console.warn("[freezmo] could not save the chat", error));
}
function removeFromDb(id: string): void {
void withDb("readwrite", (db) => {
const transaction = db.transaction(CHATS, "readwrite");
transaction.objectStore(CHATS).delete(id);
}).catch((error) => console.warn("[freezmo] could not delete the chat", error));
}
/** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */
export function ensureChatsHydrated(): Promise<void> {
if (hydrated) return Promise.resolve();
if (hydration) return hydration;
hydration = (async () => {
if (typeof indexedDB === "undefined") {
hydrated = true;
return;
}
try {
await withDb("readonly", async (db) => {
const transaction = db.transaction(CHATS, "readonly");
const loaded = await requestToPromise(transaction.objectStore(CHATS).getAll() as IDBRequest<unknown[]>);
const known = new Set(conversations.map((entry) => entry.id));
const valid: ChatConversation[] = [];
for (const raw of loaded) {
const parsed = chatConversationSchema.safeParse(raw);
if (!parsed.success) continue;
if (known.has(parsed.data.id)) continue;
valid.push(parsed.data);
}
conversations = [...conversations, ...valid]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_CONVERSATIONS);
});
} catch (error) {
console.warn("[freezmo] could not read the chat history", error);
} finally {
hydrated = true;
notify();
}
})();
return hydration;
}
export const chatHistoryStore = {
subscribe(listener: Listener): () => void {
listeners.add(listener);
void ensureChatsHydrated();
return () => listeners.delete(listener);
},
getSnapshot(): ChatConversation[] {
return cached;
},
getServerSnapshot(): ChatConversation[] {
return EMPTY;
},
};
export function getConversation(id: string): ChatConversation | undefined {
return conversations.find((entry) => entry.id === id);
}
export function conversationMessages(conversation: ChatConversation): ChatMessage[] {
return conversation.messages.slice(-MAX_MESSAGES_PER_CHAT);
}
/** Creates or replaces one conversation and persists it. */
export function saveConversation(conversation: ChatConversation): void {
const parsed = chatConversationSchema.safeParse({
...conversation,
messages: conversation.messages.slice(-MAX_MESSAGES_PER_CHAT),
});
if (!parsed.success) {
console.warn("[freezmo] skipping invalid chat", parsed.error.issues[0]);
return;
}
const next = parsed.data;
conversations = [next, ...conversations.filter((entry) => entry.id !== next.id)]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_CONVERSATIONS);
notify();
persist(next);
markSyncDirty();
}
export function renameConversation(id: string, title: string): void {
const trimmed = title.trim().slice(0, 200);
if (!trimmed) return;
const current = getConversation(id);
if (!current) return;
saveConversation({ ...current, title: trimmed, updatedAt: Date.now() });
}
export function deleteConversation(id: string): void {
conversations = conversations.filter((entry) => entry.id !== id);
notify();
removeFromDb(id);
markSyncDirty();
}
/** Used by backup import and sync: wholesale replacement (no dirty marking —
callers that need it wrap with withoutDirtyTracking). */
export function replaceChats(next: ChatConversation[]): void {
const valid: ChatConversation[] = [];
for (const raw of next) {
const parsed = chatConversationSchema.safeParse(raw);
if (parsed.success) valid.push(parsed.data);
}
conversations = valid.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS);
notify();
void withDb("readwrite", (db) => {
const transaction = db.transaction(CHATS, "readwrite");
transaction.objectStore(CHATS).clear();
for (const conversation of conversations) transaction.objectStore(CHATS).put(conversation);
}).catch((error) => console.warn("[freezmo] could not replace the chat history", error));
}

View file

@ -1,53 +1,17 @@
"use client"; "use client";
/** /**
* Chat types shared by the chat UI, the context builder and the streaming * Chat helpers and types shared by the chat UI. The record shapes themselves
* client. The API key rule applies here too: nothing in a chat request may * live in `lib/schemas.ts` (zod, shared with backup/sync); this module
* carry the key anywhere except the `x-ollama-key` header of our own route. * re-exports them so UI code has one import.
*/ */
export type ChatRole = "user" | "assistant"; import type { ChatAttachment, ChatMessage, ChatScope, ChatSource } from "./schemas";
/** A context chunk that actually travelled with a request. */ export type { ChatAttachment, ChatMessage, ChatScope, ChatSource };
export type ChatSource = {
deckId: string;
deckTitle: string;
section: string;
};
export type ChatAttachment = {
name: string;
kind: "photo" | "document" | "text";
chars: number;
};
export type ChatError = { message: string; kind?: string }; export type ChatError = { message: string; kind?: string };
export type ChatMessage = {
id: string;
role: ChatRole;
content: string;
/** Reasoning streamed separately; hidden behind the Thinking row. */
thinking?: string;
/** Model that produced an assistant turn. */
model?: string;
/** Context chunks sent with this turn (assistant turns display them). */
sources?: ChatSource[];
attachments?: ChatAttachment[];
at: number;
/** Set when an assistant turn failed; content may hold a partial answer. */
error?: ChatError;
};
/**
* What the chat is allowed to ground on. `all` = every deck with notes,
* `none` = general chat, `decks` = a chosen set.
*/
export type ChatScope =
| { kind: "all" }
| { kind: "none" }
| { kind: "decks"; deckIds: string[] };
export type ChatModelChoice = { export type ChatModelChoice = {
name: string; name: string;
badge: "text" | "vision" | "both"; badge: "text" | "vision" | "both";
@ -81,3 +45,28 @@ export function chatMessageId(prefix: string): string {
: Math.random().toString(36).slice(2, 10); : Math.random().toString(36).slice(2, 10);
return `${prefix}_${Date.now().toString(36)}_${rand}`; return `${prefix}_${Date.now().toString(36)}_${rand}`;
} }
/** Auto title from the first user turn (markdown stripped, one line). */
export function titleFromMessage(content: string): string {
const line = content
.replace(/[#*`>_]/g, "")
.replace(/\s+/g, " ")
.trim();
if (!line) return "";
return line.length > 60 ? `${line.slice(0, 57)}…` : line;
}
/** Case/diacritic-insensitive search over titles and message text. */
export function conversationMatches(title: string, messages: ChatMessage[], query: string): boolean {
const needle = fold(query);
if (!needle) return true;
if (fold(title).includes(needle)) return true;
return messages.some((message) => fold(message.content).includes(needle));
}
function fold(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "");
}

View file

@ -10,6 +10,8 @@ export type DeckSeed = {
text: string; text: string;
difficulty?: "easy" | "medium" | "hard"; difficulty?: "easy" | "medium" | "hard";
count?: number; count?: number;
/** Wizard step to land on (chat's "turn into flashcards" opens options). */
step?: 1 | 2 | 3;
}; };
const KEY = "freezmo.deck.seed.v1"; const KEY = "freezmo.deck.seed.v1";

View file

@ -160,7 +160,7 @@ const en: Record<string, string> = {
"settings.exportAll": "Export all data", "settings.exportAll": "Export all data",
"settings.exportNote": "Decks, homework, progress and settings — never your API key.", "settings.exportNote": "Decks, homework, progress and settings — never your API key.",
"settings.importBackup": "Import backup", "settings.importBackup": "Import backup",
"settings.importPreview": "This backup has {decks} decks, {homework} homework results and {folders} folders.", "settings.importPreview": "This backup has {decks} decks, {homework} homework results, {folders} folders and {chats} chats.",
"settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.", "settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.",
"settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.", "settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
"settings.importMerge": "Merge", "settings.importMerge": "Merge",
@ -975,6 +975,18 @@ const en: Record<string, string> = {
"chat.seedSteps": "Steps", "chat.seedSteps": "Steps",
"chat.notesDeleted": "Notes text deleted", "chat.notesDeleted": "Notes text deleted",
"chat.deleteNotes": "Delete notes text", "chat.deleteNotes": "Delete notes text",
"chat.history": "Chats",
"chat.searchChats": "Search chats",
"chat.noResults": "No chats match that search.",
"chat.noHistory": "No saved chats yet — ask something and it will appear here.",
"chat.untitled": "Untitled chat",
"chat.deleteChat": "Delete chat",
"chat.deletedChat": "Chat deleted",
"chat.toFlashcards": "Turn into flashcards",
"chat.saveToNotes": "Save to notes",
"chat.savedToNotes": "Saved to {deck}'s notes",
"chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)",
"chat.savedFromChat": "Saved from chat",
"settings.chatNotes": "Keep notes text for chat", "settings.chatNotes": "Keep notes text for chat",
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.", "settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.", "server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
@ -1080,7 +1092,7 @@ const cs: Record<string, string> = {
"settings.exportAll": "Exportovat všechna data", "settings.exportAll": "Exportovat všechna data",
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.", "settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
"settings.importBackup": "Importovat zálohu", "settings.importBackup": "Importovat zálohu",
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů a {folders} složek.", "settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {folders} složek a {chats} chatů.",
"settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.", "settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.",
"settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.", "settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
"settings.importMerge": "Sloučit", "settings.importMerge": "Sloučit",
@ -1895,6 +1907,18 @@ const cs: Record<string, string> = {
"chat.seedSteps": "Postup", "chat.seedSteps": "Postup",
"chat.notesDeleted": "Text poznámek smazán", "chat.notesDeleted": "Text poznámek smazán",
"chat.deleteNotes": "Smazat text poznámek", "chat.deleteNotes": "Smazat text poznámek",
"chat.history": "Chaty",
"chat.searchChats": "Hledat v chatech",
"chat.noResults": "Tomuto hledání nic neodpovídá.",
"chat.noHistory": "Zatím žádné uložené chaty — na něco se zeptej a objeví se tady.",
"chat.untitled": "Chat bez názvu",
"chat.deleteChat": "Smazat chat",
"chat.deletedChat": "Chat smazán",
"chat.toFlashcards": "Převést na kartičky",
"chat.saveToNotes": "Uložit do poznámek",
"chat.savedToNotes": "Uloženo do poznámek sady {deck}",
"chat.savedToNotesTrimmed": "Uloženo do poznámek sady {deck} (zkráceno, aby se vešlo)",
"chat.savedFromChat": "Uloženo z chatu",
"settings.chatNotes": "Uchovávat text poznámek pro chat", "settings.chatNotes": "Uchovávat text poznámek pro chat",
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.", "settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.", "server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",

View file

@ -339,3 +339,52 @@ export const srsRecordSchema = z.object({
lastCorrect: z.boolean(), lastCorrect: z.boolean(),
}); });
export type SrsRecord = z.infer<typeof srsRecordSchema>; export type SrsRecord = z.infer<typeof srsRecordSchema>;
/* ------------------------------------------------------------------ */
/* Chat (history, sources, scopes) */
/* ------------------------------------------------------------------ */
export const chatSourceSchema = z.object({
deckId: z.string().min(1).max(120),
deckTitle: z.string().max(200),
section: z.string().max(200),
});
export type ChatSource = z.infer<typeof chatSourceSchema>;
export const chatAttachmentSchema = z.object({
name: z.string().max(200),
kind: z.enum(["photo", "document", "text"]),
chars: z.number().int().min(0).max(10_000_000),
});
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
export const chatMessageSchema = z.object({
id: z.string().min(1).max(80),
role: z.enum(["user", "assistant"]),
content: z.string().max(20_000),
thinking: z.string().max(20_000).optional(),
model: z.string().max(120).optional(),
sources: z.array(chatSourceSchema).max(40).optional(),
attachments: z.array(chatAttachmentSchema).max(10).optional(),
at: z.number(),
error: z.object({ message: z.string().max(2000), kind: z.string().max(40).optional() }).optional(),
});
export type ChatMessage = z.infer<typeof chatMessageSchema>;
export const chatScopeSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("all") }),
z.object({ kind: z.literal("none") }),
z.object({ kind: z.literal("decks"), deckIds: z.array(z.string().max(120)).max(200) }),
]);
export type ChatScope = z.infer<typeof chatScopeSchema>;
export const chatConversationSchema = z.object({
id: z.string().min(1).max(80),
title: z.string().max(200),
messages: z.array(chatMessageSchema).max(300),
scope: chatScopeSchema,
model: z.string().max(120).optional(),
createdAt: z.number(),
updatedAt: z.number(),
});
export type ChatConversation = z.infer<typeof chatConversationSchema>;

View file

@ -13,6 +13,7 @@
import { z } from "zod"; import { z } from "zod";
import { backupDataSchema } from "./backup"; import { backupDataSchema } from "./backup";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { ApiError } from "./client-api"; import { ApiError } from "./client-api";
import { replaceFolders } from "./folders"; import { replaceFolders } from "./folders";
import { replaceHomework } from "./homework"; import { replaceHomework } from "./homework";
@ -162,6 +163,7 @@ async function apiDelete(id: string): Promise<void> {
async function buildPayload(): Promise<SyncPayload> { async function buildPayload(): Promise<SyncPayload> {
await ensureLearningHydrated(); await ensureLearningHydrated();
await ensureChatsHydrated();
return { return {
app: "freezmo", app: "freezmo",
kind: "sync", kind: "sync",
@ -173,6 +175,7 @@ async function buildPayload(): Promise<SyncPayload> {
homework: homeworkStore.getSnapshot(), homework: homeworkStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(), history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(), srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
}, },
}; };
} }
@ -185,6 +188,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
replaceFolders(parsed.data.folders); replaceFolders(parsed.data.folders);
replaceDecks(parsed.data.decks); replaceDecks(parsed.data.decks);
replaceHomework(parsed.data.homework); replaceHomework(parsed.data.homework);
replaceChats(parsed.data.chats ?? []);
}); });
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []); await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false }); setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v32"; const CACHE_VERSION = "freezmo-v33";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -37,7 +37,7 @@ prints a per-suite summary at the end.
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). | | `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. | | `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
| `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. | | `phase3-test.mjs` | Check my work (API + phone/desktop UI), follow-ups, guide step explain, turn-into-a-study-deck. |
| `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion. | | `chat-test.mjs` | Chat: streaming API (validation, allowlist, context travel, history window, thinking separation, usage-limit mapping), attachment extraction, entry points, streaming/stop, markdown + KaTeX, model/scope pickers, edit/regenerate, offline, reduced motion, plus history (IDB persistence, reload, rename/search/delete/undo, scope+model memory), assistant actions, the export payload and a two-device sync round-trip. |
## Extra instances ## Extra instances

View file

@ -8,6 +8,7 @@
* Runs against a stub Ollama, so no real model is needed. * Runs against a stub Ollama, so no real model is needed.
*/ */
import http from "node:http"; import http from "node:http";
import { readFileSync } from "node:fs";
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs"; import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
@ -296,7 +297,8 @@ try {
await phone.page.waitForTimeout(400); await phone.page.waitForTimeout(400);
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1); check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
await phone.page.getByRole("button", { name: /stub-vision/ }).click(); await phone.page.getByRole("button", { name: /stub-vision/ }).click();
await phone.page.waitForTimeout(400); await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300);
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1); check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1);
check( check(
"model: choice is remembered", "model: choice is remembered",
@ -310,7 +312,8 @@ try {
await phone.page.waitForTimeout(400); await phone.page.waitForTimeout(400);
check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1); check("scope: picker opens with the modes", (await phone.page.getByText("All notes").count()) >= 1 && (await phone.page.getByText("No notes").count()) >= 1);
await phone.page.getByRole("button", { name: /No notes/ }).first().click(); await phone.page.getByRole("button", { name: /No notes/ }).first().click();
await phone.page.waitForTimeout(400); await phone.page.getByRole("dialog", { name: "What may chat read?" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300);
check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1); check("scope: chip follows the choice", (await phone.page.getByRole("button", { name: "No notes" }).count()) === 1);
/* 9. UI: thinking row ----------------------------------------------- */ /* 9. UI: thinking row ----------------------------------------------- */
@ -389,7 +392,7 @@ try {
/* 14. UI: new chat + old-decks note --------------------------------- */ /* 14. UI: new chat + old-decks note --------------------------------- */
await phone.page.getByRole("button", { name: "New chat" }).click(); await phone.page.getByRole("button", { name: "New chat" }).click();
await phone.page.waitForTimeout(500); await phone.page.waitForTimeout(900);
check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1); check("new chat: clears the conversation", (await phone.page.locator(".chat-md").count()) === 0 && (await phone.page.getByText("Ask your notes anything").count()) === 1);
const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" }; const oldDeck = { ...DECK, id: "old_deck", title: "Imported deck", sourceText: "" };
@ -401,6 +404,200 @@ try {
check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1); check("old decks: scope chip scoped to it", (await oldPage.page.getByText("1 deck").count()) >= 1);
await oldPage.context.close(); await oldPage.context.close();
/* 16. phase 2: history, actions, sync, export ----------------------- */
{
const hist = await makePage(browser);
await seed(hist.page, { decks: [DECK_NOTES] });
await hist.page.goto(`${APP}/chat?deck=seed_deck`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1200);
await hist.page.getByRole("textbox").fill("What is photosynthesis?");
await hist.page.getByRole("button", { name: "Send", exact: true }).click();
await hist.page.waitForTimeout(2600);
const readChats = (target) =>
target.evaluate(
() =>
new Promise((resolve) => {
const request = indexedDB.open("freezmo-chat", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("chats")) db.createObjectStore("chats", { keyPath: "id" });
};
request.onsuccess = () => {
const db = request.result;
try {
const all = db.transaction("chats", "readonly").objectStore("chats").getAll();
all.onsuccess = () => {
db.close();
resolve(all.result);
};
all.onerror = () => {
db.close();
resolve([]);
};
} catch {
db.close();
resolve([]);
}
};
request.onerror = () => resolve([]);
}),
);
const stored = await readChats(hist.page);
check("history: conversation saved to IndexedDB", stored.length === 1, String(stored.length));
check("history: auto title from the first question", stored[0]?.title === "What is photosynthesis?", stored[0]?.title);
check("history: messages + sources stored", stored[0]?.messages?.length === 2 && (stored[0]?.messages?.[1]?.sources?.length ?? 0) >= 1);
check("history: URL carries the conversation id", hist.page.url().includes("/chat?c="), hist.page.url());
// Assistant actions: save to notes + turn into flashcards.
const beforeNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
await hist.page.getByRole("button", { name: "Save to notes" }).click();
await hist.page.waitForTimeout(700);
const afterNotes = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].sourceText);
check("actions: save to notes appends to the scoped deck", afterNotes.length > beforeNotes.length && afterNotes.includes("Rayleigh"));
check("actions: saved note is labelled", afterNotes.includes("Saved from chat"));
await hist.page.getByRole("button", { name: "Turn into flashcards" }).click();
await hist.page.waitForTimeout(1600);
check(
"actions: flashcards opens the wizard on the options step",
hist.page.url().includes("/create") && (await hist.page.getByText("2 / 3").count()) === 1,
hist.page.url(),
);
await hist.page.getByRole("button", { name: "Back" }).click();
await hist.page.waitForTimeout(500);
const seededText = await hist.page.locator("textarea").first().inputValue().catch(() => "");
check("actions: answer pre-filled as notes", seededText.includes("Rayleigh"));
// Back to the conversation: it survives, sources included.
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check("history: conversation survives navigation + reload", (await hist.page.locator(".chat-md").count()) === 1);
check("history: sources chips survive too", (await hist.page.locator('a[href="/deck/seed_deck"]').count()) >= 1);
// Rename + search + delete/undo in the phone sheet.
await hist.page.getByRole("button", { name: "Chats" }).click();
await hist.page.waitForTimeout(600);
const sheet = hist.page.getByRole("dialog", { name: "Chats" });
check("history: phone sheet lists the chat", (await sheet.getByText("What is photosynthesis?").count()) >= 1);
await sheet.getByRole("button", { name: "Rename" }).click();
await sheet.getByRole("textbox", { name: "Rename" }).fill("Photosynthesis chat");
await sheet.getByRole("button", { name: "Save" }).click();
await hist.page.waitForTimeout(700);
check("history: rename updates the list", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
check("history: rename persisted", (await readChats(hist.page))[0]?.title === "Photosynthesis chat");
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("zzz-nothing");
await hist.page.waitForTimeout(400);
check("history: search filters and explains", (await sheet.getByText("No chats match that search.").count()) === 1);
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("photosynthesis");
await hist.page.waitForTimeout(400);
check("history: search finds by title", (await sheet.getByText("Photosynthesis chat").count()) >= 1);
await sheet.getByRole("searchbox", { name: "Search chats" }).fill("");
await sheet.getByRole("button", { name: "Delete chat" }).click();
await hist.page.waitForTimeout(800);
check("history: delete removes the chat", (await sheet.getByText("Photosynthesis chat").count()) === 0 && (await readChats(hist.page)).length === 0);
await hist.page.getByRole("button", { name: "Undo" }).click();
await hist.page.waitForTimeout(800);
check("history: undo restores the chat", (await readChats(hist.page)).length === 1);
await sheet.getByRole("button", { name: "New chat" }).click();
await hist.page.waitForTimeout(700);
check(
"history: new chat clears the conversation",
(await hist.page.locator(".chat-md").count()) === 0 && (await hist.page.getByText("Ask your notes anything").count()) === 1,
);
// Open from the list again; scope is remembered across a reload.
await hist.page.getByRole("button", { name: "Chats" }).click();
await hist.page.waitForTimeout(600);
await sheet.getByText("Photosynthesis chat").click();
await hist.page.waitForTimeout(1400);
check("history: opening from the list loads it", (await hist.page.locator(".chat-md").count()) === 1);
await hist.page.getByRole("button", { name: /1 deck/ }).click();
await hist.page.waitForTimeout(400);
await hist.page.getByRole("button", { name: /No notes/ }).first().click();
await hist.page.waitForTimeout(500);
await hist.page.reload({ waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check("history: scope remembered after reload", (await hist.page.getByRole("button", { name: "No notes" }).count()) === 1);
// Offline: history readable, sending paused.
await hist.page.context().setOffline(true);
await hist.page.waitForTimeout(400);
check(
"history: readable offline with sending paused",
(await hist.page.locator(".chat-md").count()) === 1 && (await hist.page.getByText(/Offline — sending is paused/).count()) === 1,
);
await hist.page.context().setOffline(false);
// Export includes chats and never the key.
await hist.page.goto(APP, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(900);
await hist.page.locator("nav.tabbar").getByRole("button").click();
await hist.page.getByRole("dialog").waitFor({ timeout: 6000 });
await hist.page.waitForTimeout(700);
const downloadPromise = hist.page.waitForEvent("download");
await hist.page.getByRole("button", { name: "Export all data" }).click();
const download = await downloadPromise;
const raw = readFileSync(await download.path(), "utf8");
const backup = JSON.parse(raw);
check("export: chats included, API key excluded", backup.data.chats?.length === 1 && !raw.includes("test-key"), JSON.stringify(backup.data.chats?.length));
// Encrypted sync carries chats to a second device.
await hist.page.getByRole("button", { name: "Turn on sync" }).click();
await hist.page.waitForTimeout(300);
await hist.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await hist.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
await hist.page.getByRole("button", { name: "Start syncing" }).click();
await hist.page.waitForTimeout(3000);
const syncId = await hist.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).id);
check("sync: enabled with chats in the payload", /^[a-f0-9]{32}$/.test(syncId), syncId);
await hist.page.keyboard.press("Escape");
await hist.page.waitForTimeout(500);
const second = await makePage(browser);
await seed(second.page, { decks: [] });
await second.page.goto(APP, { waitUntil: "networkidle" });
await second.page.waitForTimeout(900);
await second.page.locator("nav.tabbar").getByRole("button").click();
await second.page.getByRole("dialog").waitFor({ timeout: 6000 });
await second.page.waitForTimeout(500);
await second.page.getByRole("button", { name: "Join another sync" }).click();
await second.page.waitForTimeout(300);
await second.page.getByPlaceholder("Sync ID").fill(syncId);
await second.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await second.page.getByRole("button", { name: "Join" }).click();
await second.page.waitForTimeout(3500);
const secondChats = await readChats(second.page);
check("sync: joined device received the chat", secondChats.length === 1 && secondChats[0].title === "Photosynthesis chat", JSON.stringify(secondChats.length));
// Clean up the server copy so the shared sync dir stays tidy.
await second.page.getByRole("button", { name: "Delete synced data" }).click();
await second.page.waitForTimeout(300);
await second.page.getByRole("button", { name: "Delete", exact: true }).click();
await second.page.waitForTimeout(1500);
const gone = await second.page.evaluate(async (id) => {
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
return response.status;
}, syncId);
check("sync: server copy removed after the test", gone === 404, String(gone));
await second.context.close();
// Desktop: the history panel sits next to the conversation.
await hist.page.setViewportSize({ width: 1280, height: 900 });
await hist.page.goto(`${APP}/chat?c=${stored[0].id}`, { waitUntil: "networkidle" });
await hist.page.waitForTimeout(1600);
check(
"desktop: history panel lists the chat",
(await hist.page.locator("aside").getByText("Photosynthesis chat").count()) >= 1 && (await hist.page.locator(".chat-md").count()) === 1,
);
await hist.context.close();
}
/* 15. reduced motion still works ------------------------------------ */ /* 15. reduced motion still works ------------------------------------ */
const reduced = await makePage(browser, { reducedMotion: "reduce" }); const reduced = await makePage(browser, { reducedMotion: "reduce" });
await seed(reduced.page, { decks: [DECK_NOTES] }); await seed(reduced.page, { decks: [DECK_NOTES] });