From 5ca0de920c5ebe034229bdad96e5334673561f0b Mon Sep 17 00:00:00 2001
From: Martin Dvorak
Date: Sun, 11 Oct 2026 19:03:48 +0000
Subject: [PATCH] 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= 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.
---
README.md | 28 +++-
components/DataSection.tsx | 1 +
components/UploadScreen.tsx | 1 +
components/chat/ChatScreen.tsx | 241 +++++++++++++++++++++++++++---
components/chat/Composer.tsx | 2 +-
components/chat/ContextPicker.tsx | 72 ++++++---
components/chat/HistoryPanel.tsx | 205 +++++++++++++++++++++++++
components/chat/MessageRow.tsx | 26 +++-
components/chat/ModelPicker.tsx | 72 ++++++---
lib/backup.ts | 16 +-
lib/chat-store.ts | 195 ++++++++++++++++++++++++
lib/chat.ts | 71 ++++-----
lib/deck-seed.ts | 2 +
lib/i18n-dict.ts | 28 +++-
lib/schemas.ts | 49 ++++++
lib/sync.ts | 4 +
public/sw.js | 2 +-
tests/README.md | 2 +-
tests/chat-test.mjs | 203 ++++++++++++++++++++++++-
19 files changed, 1107 insertions(+), 113 deletions(-)
create mode 100644 components/chat/HistoryPanel.tsx
create mode 100644 lib/chat-store.ts
diff --git a/README.md b/README.md
index 0f78f7a..c6d6ff6 100644
--- a/README.md
+++ b/README.md
@@ -711,6 +711,14 @@ have a step explained without leaving the app.
allowlist and never accepts a model name from the client.
- **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first
(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=` (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
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
@@ -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
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,
- 487 checks.
+ 513 checks.
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
@@ -1183,6 +1191,24 @@ Things I chose while building, with the reasoning:
points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate,
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=` 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
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()`);
diff --git a/components/DataSection.tsx b/components/DataSection.tsx
index fb6241a..bc33178 100644
--- a/components/DataSection.tsx
+++ b/components/DataSection.tsx
@@ -92,6 +92,7 @@ export function DataSection() {
decks: pending.preview.decks.added + pending.preview.decks.replaced,
homework: pending.preview.homework.added + pending.preview.homework.replaced,
folders: pending.preview.folders.added + pending.preview.folders.replaced,
+ chats: pending.preview.chats.added + pending.preview.chats.replaced,
})}
{t("settings.importMergeNote")}
diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx
index 0efbd57..6fd6bc3 100644
--- a/components/UploadScreen.tsx
+++ b/components/UploadScreen.tsx
@@ -328,6 +328,7 @@ export function UploadScreen() {
setText(seed.text);
if (seed.difficulty) setDifficulty(seed.difficulty);
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). */
diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx
index 930752b..70412a3 100644
--- a/components/chat/ChatScreen.tsx
+++ b/components/chat/ChatScreen.tsx
@@ -7,9 +7,10 @@ import Link from "next/link";
import { Composer, type PendingAttachment } from "./Composer";
import { ContextPicker } from "./ContextPicker";
+import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow";
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 { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui";
@@ -17,24 +18,30 @@ import { ApiError } from "@/lib/client-api";
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
+import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
import {
chatMessageId,
readStoredChatModel,
storeChatModel,
+ titleFromMessage,
type ChatAttachment,
type ChatMessage,
type ChatModelChoice,
type ChatScope,
} from "@/lib/chat";
+import { setDeckSeed } from "@/lib/deck-seed";
import { extractDocument } from "@/lib/documents";
import { folderStore } from "@/lib/folders";
import { isImageFile, prepareImage } from "@/lib/image";
import { useI18n } from "@/lib/i18n";
+import { springGentle } from "@/lib/motion";
import { showToast } from "@/lib/toast";
-import { deckStore } from "@/lib/storage";
+import { deckStore, upsertDeck } from "@/lib/storage";
import { settingsStore } from "@/lib/settings";
+import type { ChatConversation } from "@/lib/schemas";
import { useHealth } from "@/lib/use-health";
import { useOnline } from "@/lib/use-online";
+import { motion } from "framer-motion";
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
@@ -71,6 +78,16 @@ export function ChatScreen() {
const [scopeOpen, setScopeOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
+ const [conversationId, setConversationId] = useState(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(null);
const scrollRef = useRef(null);
@@ -89,6 +106,33 @@ export function ChatScreen() {
setModel(readStoredChatModel() ?? "");
}, []);
+ /* History: hydrate once, then open ?c= conversations. */
+ useEffect(() => {
+ void ensureChatsHydrated();
+ }, []);
+
+ const loadedRef = useRef(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. */
useEffect(() => {
const viewport = window.visualViewport;
@@ -191,6 +235,33 @@ export function ChatScreen() {
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 lastUser = history[history.length - 1];
const context = buildChatContext({
@@ -211,6 +282,10 @@ export function ChatScreen() {
const controller = new AbortController();
abortRef.current = controller;
let produced = false;
+ let finalContent = "";
+ let finalThinking = "";
+ let finalModel: string | undefined;
+ let failure: { message: string; kind?: string } | null = null;
try {
await streamChat({
@@ -220,6 +295,7 @@ export function ChatScreen() {
signal: controller.signal,
onToken: (text) => {
produced = true;
+ finalContent += text;
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, content: message.content + text } : message,
@@ -227,6 +303,7 @@ export function ChatScreen() {
);
},
onThinking: (text) => {
+ finalThinking += text;
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
@@ -234,6 +311,7 @@ export function ChatScreen() {
);
},
onDone: (modelName) => {
+ finalModel = modelName;
setMessages((previous) =>
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
);
@@ -244,7 +322,8 @@ export function ChatScreen() {
if (apiError.kind === "aborted") {
// Stopped by the user: keep whatever streamed in.
} else {
- setError({ message: apiError.message, kind: apiError.kind });
+ failure = { message: apiError.message, kind: apiError.kind };
+ setError(failure);
if (!produced) {
// Nothing arrived: take the placeholder away and give the text back.
setMessages(history);
@@ -253,7 +332,7 @@ export function ChatScreen() {
setMessages((previous) =>
previous.map((message) =>
message.id === assistantId
- ? { ...message, error: { message: apiError.message, kind: apiError.kind } }
+ ? { ...message, error: failure ?? undefined }
: message,
),
);
@@ -262,6 +341,21 @@ export function ChatScreen() {
} finally {
abortRef.current = null;
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 = () => {
abortRef.current?.abort();
+ loadedRef.current = null;
+ setConversationId(null);
+ setTitle("");
setMessages([]);
setSeed(null);
setAttachments([]);
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) => {
@@ -346,7 +500,25 @@ export function ChatScreen() {
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
return (
-
+
+ {/* Desktop: the conversation list lives in its own column. */}
+
+
+