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

@ -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). |
| `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. |
| `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

View file

@ -8,6 +8,7 @@
* Runs against a stub Ollama, so no real model is needed.
*/
import http from "node:http";
import { readFileSync } from "node:fs";
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
@ -296,7 +297,8 @@ try {
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);
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 is remembered",
@ -310,7 +312,8 @@ try {
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);
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);
/* 9. UI: thinking row ----------------------------------------------- */
@ -389,7 +392,7 @@ try {
/* 14. UI: new chat + old-decks note --------------------------------- */
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);
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);
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 ------------------------------------ */
const reduced = await makePage(browser, { reducedMotion: "reduce" });
await seed(reduced.page, { decks: [DECK_NOTES] });