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. */} + + +
-

{t("chat.title")}

+

{title || t("chat.title")}

{scopeText} {seed ? ` · ${seed.label}` : ""}

+
+ + setHistoryOpen(false)} + conversations={conversations} + currentId={conversationId} + query={query} + onQueryChange={setQuery} + onOpen={openConversation} + onRename={renameConversation} + onDelete={handleDeleteConversation} + onNew={newChat} + />
); } diff --git a/components/chat/Composer.tsx b/components/chat/Composer.tsx index 3a4e9d5..7222c5d 100644 --- a/components/chat/Composer.tsx +++ b/components/chat/Composer.tsx @@ -122,7 +122,7 @@ export function Composer({ rows={1} placeholder={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 ? ( diff --git a/components/chat/ContextPicker.tsx b/components/chat/ContextPicker.tsx index f1d525f..d98e491 100644 --- a/components/chat/ContextPicker.tsx +++ b/components/chat/ContextPicker.tsx @@ -1,9 +1,11 @@ "use client"; import { useEffect } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; +import { springSheet } from "@/lib/motion"; import { useIsPhone } from "@/lib/use-phone"; import { deckHasNotes } from "@/lib/chat-context"; import type { ChatScope } from "@/lib/chat"; @@ -31,6 +33,7 @@ export function ContextPicker({ }) { const { t, plural } = useI18n(); const isPhone = useIsPhone(); + const reduceMotion = useReducedMotion(); useEffect(() => { if (!open) return; @@ -41,8 +44,6 @@ export function ContextPicker({ return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); - if (!open) return null; - const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set(); const choose = (next: ChatScope) => { @@ -171,29 +172,56 @@ export function ContextPicker({ if (isPhone) { return ( -
- +
+ + {visible.length === 0 ? ( +

+ {query ? t("chat.noResults") : t("chat.noHistory")} +

+ ) : ( +
    + {visible.map((conversation) => { + const active = conversation.id === currentId; + return ( +
  • + {renaming === conversation.id ? ( +
    { + event.preventDefault(); + commit(); + }} + className="flex items-center gap-1 px-1 py-1" + > + 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]" + /> + + +
    + ) : ( +
    + + + +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} + +/** 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 ( + + {open ? ( +
+ + +
+

{t("chat.history")}

+ + +
+ ) : null} + + ); +} diff --git a/components/chat/MessageRow.tsx b/components/chat/MessageRow.tsx index 0a8004b..c466060 100644 --- a/components/chat/MessageRow.tsx +++ b/components/chat/MessageRow.tsx @@ -7,7 +7,7 @@ import { Markdown } from "./Markdown"; import { ThinkingRow } from "./ThinkingRow"; import { Button } from "@/components/ui"; 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 type { ChatMessage } from "@/lib/chat"; @@ -24,6 +24,8 @@ export function MessageRow({ onRegenerate, onEdit, onRetry, + onToFlashcards, + onSaveNotes, }: { message: ChatMessage; streaming?: boolean; @@ -33,6 +35,8 @@ export function MessageRow({ onRegenerate?: () => void; onEdit?: () => void; onRetry?: () => void; + onToFlashcards?: () => void; + onSaveNotes?: () => void; }) { const { t } = useI18n(); @@ -108,6 +112,26 @@ export function MessageRow({ {t("chat.regenerate")} ) : null} + {onToFlashcards ? ( + + ) : null} + {onSaveNotes ? ( + + ) : null} {message.model ? {message.model} : null}
) : null} diff --git a/components/chat/ModelPicker.tsx b/components/chat/ModelPicker.tsx index 5f40b53..6391405 100644 --- a/components/chat/ModelPicker.tsx +++ b/components/chat/ModelPicker.tsx @@ -1,9 +1,11 @@ "use client"; import { useEffect } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { CheckCircleIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; +import { springSheet } from "@/lib/motion"; import { useIsPhone } from "@/lib/use-phone"; import type { ChatModelChoice } from "@/lib/chat"; import { cn } from "@/lib/utils"; @@ -28,6 +30,7 @@ export function ModelPicker({ }) { const { t } = useI18n(); const isPhone = useIsPhone(); + const reduceMotion = useReducedMotion(); useEffect(() => { if (!open) return; @@ -38,8 +41,6 @@ export function ModelPicker({ return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); - if (!open) return null; - const badge = (model: ChatModelChoice) => model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); @@ -87,29 +88,56 @@ export function ModelPicker({ if (isPhone) { return ( -
-