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

@ -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<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 scrollRef = useRef<HTMLDivElement>(null);
@ -89,6 +106,33 @@ export function ChatScreen() {
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. */
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 (
<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">
<button
type="button"
@ -358,12 +530,21 @@ export function ChatScreen() {
<ChevronLeftIcon className="size-5" />
</button>
<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)]">
{scopeText}
{seed ? ` · ${seed.label}` : ""}
</p>
</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" />
<button
type="button"
@ -418,17 +599,25 @@ export function ChatScreen() {
</div>
) : null}
{messages.map((message) => (
<MessageRow
<motion.div
key={message.id}
message={message}
streaming={busy && message.id === lastAssistantId}
isLastAssistant={message.id === lastAssistantId}
isLastUser={message.id === lastUserId}
busy={busy}
onRegenerate={regenerate}
onEdit={editLast}
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
/>
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduceMotion ? { duration: 0 } : springGentle}
>
<MessageRow
message={message}
streaming={busy && message.id === lastAssistantId}
isLastAssistant={message.id === lastAssistantId}
isLastUser={message.id === lastUserId}
busy={busy}
onRegenerate={regenerate}
onEdit={editLast}
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) => {
setModel(next);
storeChatModel(next);
if (messages.length > 0) persist(messages, { model: next });
}}
/>
<ContextPicker
open={scopeOpen}
onClose={() => setScopeOpen(false)}
scope={scope}
onChange={setScope}
onChange={(next) => {
setScope(next);
if (messages.length > 0) persist(messages, { scope: next });
}}
decks={decks}
folders={folders}
/>
@ -515,6 +708,20 @@ export function ChatScreen() {
</Link>
</p>
) : null}
</div>
<HistorySheet
open={historyOpen}
onClose={() => setHistoryOpen(false)}
conversations={conversations}
currentId={conversationId}
query={query}
onQueryChange={setQuery}
onOpen={openConversation}
onRename={renameConversation}
onDelete={handleDeleteConversation}
onNew={newChat}
/>
</div>
);
}