diff --git a/README.md b/README.md index 3919fa1..fb044e5 100644 --- a/README.md +++ b/README.md @@ -1423,6 +1423,35 @@ Things I chose while building, with the reasoning: (measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as "cut off next to the sidebar". +89. **Codebase-wide audit (bugs, performance, dead code, edge cases).** + - *Bugs fixed:* Backup schema now accepts `theme: "aero"` (backups exported under Frutiger Aero + used to fail `parseBackup` on import), and `applyImport` in `"replace"` mode now replaces SRS + schedules, answer history and chats instead of merging into existing SRS or skipping empty + arrays; typed `FillBlank` cards (without word-bank options) now bind the `` to the live + typed state (`chosen ?? value` instead of `chosen ?? ""`) and preserve non-ASCII Unicode + letters (`\p{L}\p{N}`) when checking answers; OpenAI-compatible vision calls (`contentFor` in + `lib/openai-compat.ts`) now prefix `data:image/jpeg;base64,` when routes pass stripped base64; + streaming calls (`ollamaChatStream`, `providerChatStream`) keep their `AbortController` + attached for the entire body stream and API routes forward `request.signal` so stopping a chat + or cancelling a run aborts the upstream request immediately; `HomeworkCard` uses + `homeworkModeLabelKey` so `"check"` records show "Checked" instead of "Step-by-step guide"; + desktop dropzones on `/create` and `/homework` accept dropped documents as well as images; + `LibraryScreen` excludes reported questions from its due and mistake counts (matching + `ReviewClient`); clearing a provider key resets `generation` if it pointed at that provider, + chat conversations persist and restore `provider`, and `ModelPicker` keys items by + `provider:name`; `RouteTransition` no longer mutates module-level `lastPath` during render; + `public/sw.js` falls back to `Response.error()` when an uncached asset fetch fails offline; + confetti and `.ice-orb`/`.ice-bubble` respect the in-app `reduce-motion` class. + - *Performance & cleanup:* The six `localStorage` stores now attach a single `storage` listener + while subscribed instead of one per subscriber (avoiding $N^2$ notifications and $N\times$ JSON + parses); `LibraryScreen` uses `todayAnswerCount` instead of building the full 35-day stats + object on every render and passes sorted `folders` down to `DeckCard`; `MessageRow` is wrapped + in `React.memo` with stable callbacks so streaming tokens only re-render the active message; + `prepareImageFromBlob` closes its `ImageBitmap` and `extractPdf` destroys the `pdf.js` + document proxy in `finally`; `POST /api/sync` no longer re-parses the serialized sync blob; + `lib/share.ts` reuses `copyText` from `lib/clipboard.ts` (including its HTTP LAN fallback); + and unused helpers and `create-next-app` SVG placeholders in `public/` were removed. + 88. **A custom 404, Frutiger Aero style.** Unknown URLs used to fall through to Next's default "This page could not be found". `app/not-found.tsx` now renders a standalone screen — the root layout's ice scene is the backdrop, and the glass card, the rim and the rising bubbles come from diff --git a/app/api/chat/extract/route.ts b/app/api/chat/extract/route.ts index 4d730e4..99968ef 100644 --- a/app/api/chat/extract/route.ts +++ b/app/api/chat/extract/route.ts @@ -87,6 +87,9 @@ export async function POST(request: Request) { if ("response" in gate) return gate.response; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); + const onRequestAbort = () => abort.abort(); + if (request.signal.aborted) abort.abort(); + else request.signal.addEventListener("abort", onRequestAbort, { once: true }); try { if (images.length === 0) { // Plain text needs no model call at all. @@ -117,6 +120,7 @@ export async function POST(request: Request) { ); } finally { clearTimeout(timer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); } } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 2a0e3e6..738710c 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -158,6 +158,9 @@ export async function POST(request: Request) { const encode = encoder(); const abort = new AbortController(); const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs); + const onRequestAbort = () => abort.abort(); + if (request.signal.aborted) abort.abort(); + else request.signal.addEventListener("abort", onRequestAbort, { once: true }); const stream = new ReadableStream({ async start(controller) { @@ -208,6 +211,7 @@ export async function POST(request: Request) { } } finally { clearTimeout(globalTimer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); try { controller.close(); @@ -218,6 +222,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); abort.abort(); }, diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 5e52c7f..7acbe6f 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -131,6 +131,9 @@ export async function POST(request: Request) { const abort = new AbortController(); // A single hard deadline for the whole pipeline (two sequential model calls). const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2); + const onRequestAbort = () => abort.abort(); + if (request.signal.aborted) abort.abort(); + else request.signal.addEventListener("abort", onRequestAbort, { once: true }); // Heartbeats: one quiet model call can run for minutes, and the client's // stall guard needs to tell "slow" from "dead" — a silent stream is how the // wizard ended up loading forever. Cleared with the stream either way. @@ -234,6 +237,7 @@ export async function POST(request: Request) { } finally { if (heartbeat) clearInterval(heartbeat); clearTimeout(globalTimer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); try { controller.close(); @@ -245,6 +249,7 @@ export async function POST(request: Request) { cancel() { if (heartbeat) clearInterval(heartbeat); clearTimeout(globalTimer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); abort.abort(); }, diff --git a/app/api/homework/refine/route.ts b/app/api/homework/refine/route.ts index 6c29b8f..21f9526 100644 --- a/app/api/homework/refine/route.ts +++ b/app/api/homework/refine/route.ts @@ -81,6 +81,11 @@ export async function POST(request: Request) { const gate = acquireModelGate(request, config, locale); if ("response" in gate) return gate.response; + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), Math.min(config.timeoutMs, 180_000)); + const onRequestAbort = () => abort.abort(); + if (request.signal.aborted) abort.abort(); + else request.signal.addEventListener("abort", onRequestAbort, { once: true }); try { const text = await refineHomeworkText({ model: textModel, @@ -89,6 +94,7 @@ export async function POST(request: Request) { instruction: parsed.data.instruction, text: parsed.data.text, context: parsed.data.context, + signal: abort.signal, }); return Response.json( { text, model: provider ? textModel : resolveModelName(config.textModel, baseAuth.directCloud) }, @@ -105,6 +111,8 @@ export async function POST(request: Request) { { status: 502 }, ); } finally { + clearTimeout(timer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); } } diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts index 802a66e..b4b12fd 100644 --- a/app/api/homework/route.ts +++ b/app/api/homework/route.ts @@ -124,6 +124,9 @@ export async function POST(request: Request) { const abort = new AbortController(); // A single hard deadline for the whole pipeline (two sequential model calls). const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2); + const onRequestAbort = () => abort.abort(); + if (request.signal.aborted) abort.abort(); + else request.signal.addEventListener("abort", onRequestAbort, { once: true }); // Heartbeats so the client's stall guard can tell "slow" from "dead". let heartbeat: ReturnType | null = null; @@ -291,6 +294,7 @@ export async function POST(request: Request) { } finally { if (heartbeat) clearInterval(heartbeat); clearTimeout(globalTimer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); try { controller.close(); @@ -302,6 +306,7 @@ export async function POST(request: Request) { cancel() { if (heartbeat) clearInterval(heartbeat); clearTimeout(globalTimer); + request.signal.removeEventListener("abort", onRequestAbort); gate.gate.release(); abort.abort(); }, diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts index fc1f1a3..c7234ed 100644 --- a/app/api/sync/route.ts +++ b/app/api/sync/route.ts @@ -144,7 +144,8 @@ export async function POST(request: Request) { } const file = fileFor(config.sync.dir, parsed.data.id); - const serialised = JSON.stringify({ updatedAt: Date.now(), envelope: parsed.data.envelope }); + const updatedAt = Date.now(); + const serialised = JSON.stringify({ updatedAt, envelope: parsed.data.envelope }); // Total storage cap, checked only when a new blob is created. const exists = await fs @@ -162,7 +163,6 @@ export async function POST(request: Request) { } await writeAtomic(file, serialised); - const updatedAt = (JSON.parse(serialised) as { updatedAt: number }).updatedAt; return Response.json({ updatedAt }, { headers: { "Cache-Control": "no-store" } }); } diff --git a/app/globals.css b/app/globals.css index 472c023..b5fbdf6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1597,10 +1597,6 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { .fade-in-mobile { animation: none; } } -@media (display-mode: standalone) { - .standalone-hide { display: none } -} - /* Phones: Apple's 44px minimum for touch targets. The class is a no-op on desktop (and on tablets), so components can carry it unconditionally. */ @media (max-width: 639px) { @@ -1657,7 +1653,17 @@ html.reduce-motion .ice-blob, html.reduce-motion .ice-orb, html.reduce-motion .ice-bubble { animation: none !important; + will-change: auto; } +html.reduce-motion.aero .ice-bubble { + opacity: 0.55; + transform: translate3d(0, -60vh, 0); +} +html.reduce-motion .ice-orb-1 { top: 14%; left: 12%; } +html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; } +html.reduce-motion .ice-orb-3 { top: auto; bottom: -70px; right: 10%; } +html.reduce-motion .ice-orb-4 { top: 24%; right: 28%; } +html.reduce-motion .ice-orb-5 { top: auto; bottom: 16%; left: 38%; } html.reduce-motion *, html.reduce-motion *::before, html.reduce-motion *::after { diff --git a/components/DataSection.tsx b/components/DataSection.tsx index 1bf771d..c32b94e 100644 --- a/components/DataSection.tsx +++ b/components/DataSection.tsx @@ -119,11 +119,7 @@ export function DataSection() { {t("settings.storage")} - {persistence === "persistent" - ? t("settings.storagePersistent") - : persistence === "unsupported" - ? t("settings.storageBestEffort") - : t("settings.storageBestEffort")} + {persistence === "persistent" ? t("settings.storagePersistent") : t("settings.storageBestEffort")} {persistence === "best-effort" ? ( diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index 541726b..bde96f4 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -10,7 +10,7 @@ import { createFolder as makeFolder, listFolders } from "@/lib/folders"; import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; import { springGentle } from "@/lib/motion"; -import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; +import { UNORGANIZED_ID, type Deck, type Folder } from "@/lib/schemas"; import { shareDeck } from "@/lib/share"; import { appendActivities, deleteDeck, deleteNotesText, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { showToast } from "@/lib/toast"; @@ -30,17 +30,19 @@ const ADD_OPTIONS = [3, 5, 8]; */ export function DeckCard({ deck, + folders: foldersProp, onMove, index = 0, }: { deck: Deck; + folders?: Folder[]; onMove?: (folderId: string | undefined) => void; /** Position in the list — used for the capped first-load stagger. */ index?: number; }) { const { t, plural } = useI18n(); const isPhone = useIsPhone(); - const folders = listFolders(); + const folders = foldersProp ?? listFolders(); const [busy, setBusy] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const [renaming, setRenaming] = useState(false); diff --git a/components/FolderBar.tsx b/components/FolderBar.tsx index 7dbff44..2abd256 100644 --- a/components/FolderBar.tsx +++ b/components/FolderBar.tsx @@ -5,10 +5,11 @@ import { useRef, useState } from "react"; import { FolderIcon, PlusIcon } from "@/components/icons"; import { createFolder } from "@/lib/folders"; import { useI18n } from "@/lib/i18n"; +import { ALL_FOLDERS } from "@/lib/nav"; import type { Folder } from "@/lib/schemas"; import { cn } from "@/lib/utils"; -export const ALL_FOLDERS = "all"; +export { ALL_FOLDERS }; /** * Folder switcher. Reads like a row of quiet pills (ChatGPT sidebar items): diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx index c085181..9255897 100644 --- a/components/HomeworkCard.tsx +++ b/components/HomeworkCard.tsx @@ -4,8 +4,9 @@ import { motion } from "framer-motion"; import Link from "next/link"; import { useState } from "react"; -import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons"; +import { BoltIcon, CheckCircleIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons"; import { Button } from "@/components/ui"; +import { homeworkModeLabelKey } from "@/lib/homework"; import { useI18n } from "@/lib/i18n"; import { springGentle } from "@/lib/motion"; import type { HomeworkRecord } from "@/lib/schemas"; @@ -70,8 +71,14 @@ export function HomeworkCard({

- {record.mode === "make" ? : } - {t(record.mode === "make" ? "homework.history.made" : "homework.history.guide")} + {record.mode === "make" ? ( + + ) : record.mode === "check" ? ( + + ) : ( + + )} + {t(homeworkModeLabelKey(record.mode))} · {formatDate(record.updatedAt)} diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index ddcf17f..ce4203c 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -16,7 +16,8 @@ import { createFolder, folderStore, renameFolder } from "@/lib/folders"; import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework"; import { learningStore } from "@/lib/history"; import { useI18n } from "@/lib/i18n"; -import { computeStats, dueItems, mistakeItems } from "@/lib/learning"; +import { dueItems, mistakeItems, todayAnswerCount } from "@/lib/learning"; +import { isReported, reportStore } from "@/lib/question-reports"; import { settingsStore } from "@/lib/settings"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { springGentle, springSheet } from "@/lib/motion"; @@ -110,17 +111,30 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | () => settingsStore.getSnapshot().dailyGoal, () => 20, ); - /* Smart-review counts resolve against the live deck list: deleting a deck - must drop its due schedules and its mistakes right away. `dueCount` is not - used directly because it counts raw schedules, including decks that no - longer exist. */ - const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]); - const due = useMemo(() => dueItems(srs).filter((item) => deckIds.has(item.deck.id)).length, [srs, deckIds]); - const mistakes = useMemo( - () => mistakeItems(answers).filter((item) => deckIds.has(item.deck.id)).length, - [answers, deckIds], + const reports = useSyncExternalStore( + reportStore.subscribe, + reportStore.getSnapshot, + reportStore.getServerSnapshot, ); - const todayCount = useMemo(() => computeStats(answers).todayCount, [answers]); + /* Smart-review counts resolve against the live deck list and reported set: + deleting a deck or reporting a question must drop its due schedules and + its mistakes right away, matching ReviewClient. */ + const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]); + const due = useMemo( + () => { + void reports; + return dueItems(srs).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id)).length; + }, + [srs, deckIds, reports], + ); + const mistakes = useMemo( + () => { + void reports; + return mistakeItems(answers).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id)).length; + }, + [answers, deckIds, reports], + ); + const todayCount = useMemo(() => todayAnswerCount(answers), [answers]); const importRef = useRef(null); const sorted = useMemo(() => sortDecks(decks), [decks]); @@ -512,6 +526,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | { upsertDeck({ ...deck, folderId }); diff --git a/components/RouteTransition.tsx b/components/RouteTransition.tsx index b8a3209..1f2b5c2 100644 --- a/components/RouteTransition.tsx +++ b/components/RouteTransition.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; import { screenVariants, tabVariants } from "@/lib/motion"; @@ -31,12 +32,18 @@ export function RouteTransition({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const reduceMotion = useReducedMotion(); - const previous = lastPath; - lastPath = pathname; + const [{ isTabSwitch, direction }] = useState(() => { + const previous = typeof window === "undefined" ? null : lastPath; + return { + isTabSwitch: previous !== null && depth(previous) === depth(pathname) && previous !== pathname, + direction: (previous === null || depth(pathname) >= depth(previous) ? 1 : -1) as 1 | -1, + }; + }); + + useEffect(() => { + lastPath = pathname; + }, [pathname]); - const isTabSwitch = previous !== null && depth(previous) === depth(pathname) && previous !== pathname; - const direction: 1 | -1 = - previous === null || depth(pathname) >= depth(previous) ? 1 : -1; const variants = isTabSwitch ? tabVariants(!!reduceMotion) : screenVariants(direction, !!reduceMotion); return ( diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index 8e8d5c7..8f5dd1f 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -138,7 +138,7 @@ export function SessionFeed({ ); const finished = index >= total; - const correct = items.filter((item) => results[item.activity.id]?.correct).length; + const correct = items.filter((item) => !skipped.includes(item.activity.id) && results[item.activity.id]?.correct).length; /** What the results slide counts: reported cards were never attempted. */ const attemptedTotal = Math.max(0, total - skipped.length); const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0; diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 9ca04d0..ebdf60b 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { ProvidersSection } from "@/components/ProvidersSection"; import { SyncSection } from "@/components/SyncSection"; import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings"; import { useBackClose } from "@/lib/use-back-close"; +import { useIsPhone } from "@/lib/use-phone"; import { cn } from "@/lib/utils"; import { openInstallSheet, useInstallAvailable } from "@/lib/install"; import { useHealth } from "@/lib/use-health"; @@ -84,21 +85,13 @@ export function SettingsDialog({ const [saved, setSaved] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false); const [canScroll, setCanScroll] = useState(false); - const [phone, setPhone] = useState(false); + // Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged. + const phone = useIsPhone(); const dragControls = useDragControls(); const reduceMotion = useReducedMotion(); const scrollRef = useRef(null); const contentRef = useRef(null); - // Swipe-to-dismiss is a phone gesture; the desktop dialog is unchanged. - useEffect(() => { - const query = window.matchMedia("(max-width: 639px)"); - const sync = () => setPhone(query.matches); - sync(); - query.addEventListener("change", sync); - return () => query.removeEventListener("change", sync); - }, []); - useEffect(() => { if (!open) return; setKeyDraft(apiKey); diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index 5493dee..9ca8b2e 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -113,19 +113,25 @@ export function StudyFeed({ deck }: { deck: Deck }) { if (!retryMode && !recordedRef.current) { recordedRef.current = true; const answered = Object.values(results); - const correct = answered.filter((entry) => entry.correct).length; + const correct = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length; const attempted = Math.max(0, total - skipped.length); const score = attempted > 0 ? (correct / attempted) * 100 : 0; // Only a run with at least one answer counts. Without this, the scroll // anchoring shift when a regenerated deck swaps in could fire a bogus // 0% attempt before the feed had snapped back to the first slide. if (answered.length > 0) { - recordAttempt(currentDeck.id, { scorePercent: score, correct, total }); + recordAttempt(currentDeck.id, { scorePercent: score, correct, total: attempted }); + setCurrentDeck((prev) => ({ + ...prev, + attempts: prev.attempts + 1, + bestScore: Math.max(prev.bestScore ?? 0, Math.round(score)), + updatedAt: Date.now(), + })); haptic("celebrate"); void celebrate("big"); } } - }, [finished, results, total, skipped.length, currentDeck.id, retryMode]); + }, [finished, queue, results, total, skipped, currentDeck.id, retryMode]); const handleAnswered = useCallback( (activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => { @@ -166,8 +172,8 @@ export function StudyFeed({ deck }: { deck: Deck }) { const attemptedTotal = Math.max(0, total - skipped.length); const correctCount = useMemo( - () => queue.filter((activity) => results[activity.id]?.correct).length, - [queue, results], + () => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length, + [queue, results, skipped], ); const restart = useCallback( diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 5d5ac66..59b4730 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -11,6 +11,7 @@ import { DOCUMENT_ACCEPT_ATTR, KIND_LABELS, describeDocumentError, + describeExtraction, documentKindOf, documentsToText, extractDocument, @@ -71,20 +72,6 @@ const MAX_IMAGES_TO_SEND = 12; /** Cap for the merged text sent to the API. */ const MAX_TEXT_TO_SEND = 100_000; -function describeExtraction( - document: ExtractedDocument, - tr: (key: string, vars?: Record) => string, - pl: (n: number, base: string, vars?: Record) => string, -): string { - const parts = [ - pl(document.words, "upload.words", { n: document.words.toLocaleString() }), - document.pages ? pl(document.pages, "upload.pages") : "", - document.images.length ? pl(document.images.length, "upload.pageImages") : "", - document.truncated ? tr("upload.truncated") : "", - ]; - return parts.filter(Boolean).join(" · "); -} - const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.ReactNode; blurbKey: string }> = [ { value: "easy", labelKey: "upload.easy", icon: , blurbKey: "upload.easyBlurb" }, { @@ -1106,7 +1093,8 @@ export function UploadScreen() { onDrop={(event) => { event.preventDefault(); setDragging(false); - void addFiles(imageFilesFromDataTransfer(event.dataTransfer)); + const dropped = Array.from(event.dataTransfer?.files ?? []); + void addFiles(dropped.length > 0 ? dropped : imageFilesFromDataTransfer(event.dataTransfer)); }} className={cn( "card relative overflow-hidden p-4 transition-all sm:p-5", diff --git a/components/activities/FillBlank.tsx b/components/activities/FillBlank.tsx index 8332d4f..d71d4db 100644 --- a/components/activities/FillBlank.tsx +++ b/components/activities/FillBlank.tsx @@ -25,7 +25,7 @@ function normalise(value: string): string { .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9\s]/g, " ") + .replace(/[^\p{L}\p{N}\s]/gu, " ") .replace(/\s+/g, " ") .trim(); } @@ -89,7 +89,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa onAnswered(correct, { userAnswer: answerValue }); }; - const displayAnswer = chosen ?? ""; + const displayAnswer = chosen ?? value; return ( <> diff --git a/components/activities/Ordering.tsx b/components/activities/Ordering.tsx index e256ff4..b56b2ae 100644 --- a/components/activities/Ordering.tsx +++ b/components/activities/Ordering.tsx @@ -20,7 +20,13 @@ type OrderingActivity = Extract; export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); - const initial = useMemo(() => shuffle(activity.items), [activity]); + const initial = useMemo(() => { + const out = shuffle(activity.items); + if (out.length > 1 && out.every((item, index) => item === activity.items[index])) { + [out[0], out[1]] = [out[1], out[0]]; + } + return out; + }, [activity]); const [items, setItems] = useState(initial); const [checked, setChecked] = useState(false); const [correct, setCorrect] = useState(false); diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx index 4bc6c33..7e7a01b 100644 --- a/components/chat/ChatScreen.tsx +++ b/components/chat/ChatScreen.tsx @@ -116,6 +116,10 @@ export function ChatScreen() { /* History: hydrate once, then open ?c= conversations. */ useEffect(() => { void ensureChatsHydrated(); + return () => { + abortRef.current?.abort(); + chatActive.set(null); + }; }, []); const loadedRef = useRef(null); @@ -129,7 +133,7 @@ export function ChatScreen() { setDraft(""); setError(null); if (found.model) setModel(found.model); - if ((found as { provider?: string }).provider) setProvider((found as { provider?: string }).provider ?? "ollama"); + if (found.provider) setProvider(found.provider); loadedRef.current = found.id; chatActive.set(found.id); }, []); @@ -188,15 +192,23 @@ export function ChatScreen() { return [...ollama, ...cloud]; }, [health, providerState]); + const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider; + const activeModel = useMemo(() => { - if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === provider)) return model; - const sameProvider = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false); - if (model && sameProvider?.name === model) return model; + if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) { + return model; + } // The server puts the default first, but a different server may not have // it — fall back to the first model that is actually available. - const firstAvailable = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false); - return (firstAvailable ?? chatModels.find((entry) => (entry.provider ?? "ollama") === provider) ?? chatModels[0])?.name ?? model; - }, [model, chatModels, provider]); + const firstAvailable = chatModels.find( + (entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false, + ); + return ( + firstAvailable ?? + chatModels.find((entry) => (entry.provider ?? "ollama") === activeProvider) ?? + chatModels[0] + )?.name ?? model; + }, [model, chatModels, activeProvider]); const scopeText = useMemo(() => { if (scope.kind === "all") return t("chat.scope.all"); @@ -266,7 +278,10 @@ export function ChatScreen() { }; const persist = useCallback( - (nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => { + ( + nextMessages: ChatMessage[], + options: { title?: string; model?: string; provider?: string; scope?: ChatScope } = {}, + ) => { const id = conversationId ?? chatMessageId("chat"); if (!conversationId) { setConversationId(id); @@ -285,12 +300,13 @@ export function ChatScreen() { messages: nextMessages, scope: options.scope ?? scope, model: options.model ?? (activeModel || undefined), + provider: options.provider ?? activeProvider, createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(), }); if (nextTitle && nextTitle !== title) setTitle(nextTitle); }, - [conversationId, title, scope, activeModel], + [conversationId, title, scope, activeModel, activeProvider], ); const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => { @@ -321,7 +337,7 @@ export function ChatScreen() { try { await streamChat({ model: activeModel || undefined, - provider, + provider: activeProvider, messages: history.map((message) => ({ role: message.role, content: message.content })), context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })), signal: controller.signal, @@ -412,28 +428,24 @@ export function ChatScreen() { void run(history, extras); }; - const retryLast = () => { + const retryLast = useCallback(() => { + if (busy) return; const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; const history = messages.slice(0, lastUserIndex + 1); + setError(null); setMessages(history); void run(history, extrasRef.current[history[lastUserIndex].id] ?? []); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [busy, messages]); - const regenerate = () => { - const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); - if (lastUserIndex < 0) return; - const history = messages.slice(0, lastUserIndex + 1); - setMessages(history); - void run(history, extrasRef.current[history[lastUserIndex].id] ?? []); - }; - - const editLast = () => { + const editLast = useCallback(() => { + if (busy) return; const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user"); if (lastUserIndex < 0) return; setDraft(messages[lastUserIndex].content); setMessages(messages.slice(0, lastUserIndex)); - }; + }, [busy, messages]); const resetView = useCallback(() => { abortRef.current?.abort(); @@ -498,28 +510,34 @@ export function ChatScreen() { 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 saveToNotes = useCallback( + (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 }), + }); + }, + [noteTargetDeck, t], + ); - const toFlashcards = (message: ChatMessage) => { - setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 }); - router.push("/create"); - }; + const toFlashcards = useCallback( + (message: ChatMessage) => { + setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 }); + router.push("/create"); + }, + [router], + ); const handleAttach = async (file: File) => { setAttachBusy(true); @@ -545,9 +563,19 @@ export function ChatScreen() { } }; - const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id; - const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id; - const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id; + let lastAssistantId: string | undefined; + let lastUserId: string | undefined; + let lastFailedAssistant: string | undefined; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message.role === "assistant") { + if (!lastAssistantId) lastAssistantId = message.id; + if (!lastFailedAssistant && message.error) lastFailedAssistant = message.id; + } else if (!lastUserId) { + lastUserId = message.id; + } + if (lastAssistantId && lastUserId && lastFailedAssistant) break; + } return (

@@ -659,11 +687,11 @@ export function ChatScreen() { isLastAssistant={message.id === lastAssistantId} isLastUser={message.id === lastUserId} busy={busy} - onRegenerate={regenerate} + onRegenerate={retryLast} onEdit={editLast} onRetry={message.id === lastFailedAssistant ? retryLast : undefined} - onToFlashcards={() => toFlashcards(message)} - onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined} + onToFlashcards={toFlashcards} + onSaveNotes={noteTargetDeck ? saveToNotes : undefined} /> ))} @@ -718,12 +746,12 @@ export function ChatScreen() { scopeLabel={scopeText} modelLabel={ activeModel - ? provider !== "ollama" - ? `${providerInfo(provider)?.label ?? provider} · ${prettyModelName(activeModel)}` + ? activeProvider !== "ollama" + ? `${providerInfo(activeProvider)?.label ?? activeProvider} · ${prettyModelName(activeModel)}` : prettyModelName(activeModel) : t("chat.modelTitle") } - modelTitle={activeModel ? `${activeModel} · ${providerInfo(provider)?.label ?? provider}` : undefined} + modelTitle={activeModel ? `${activeModel} · ${providerInfo(activeProvider)?.label ?? activeProvider}` : undefined} scopeOpen={scopeOpen} modelOpen={modelOpen} // The chips toggle their own popover (and open one at a time), so a @@ -746,13 +774,14 @@ export function ChatScreen() { onClose={() => setModelOpen(false)} models={chatModels} value={activeModel} - valueProvider={provider} + valueProvider={activeProvider} onChange={(next, nextProvider) => { - setProvider(nextProvider ?? "ollama"); - storeChatProvider(nextProvider ?? "ollama"); + const chosenProvider = nextProvider ?? "ollama"; + setProvider(chosenProvider); + storeChatProvider(chosenProvider); setModel(next); storeChatModel(next); - if (messages.length > 0) persist(messages, { model: next }); + if (messages.length > 0) persist(messages, { model: next, provider: chosenProvider }); }} /> void; onEdit?: () => void; onRetry?: () => void; - onToFlashcards?: () => void; - onSaveNotes?: () => void; + onToFlashcards?: (message: ChatMessage) => void; + onSaveNotes?: (message: ChatMessage) => void; }) { const { t } = useI18n(); @@ -117,7 +118,7 @@ export function MessageRow({ {onToFlashcards ? (
); -} +}); diff --git a/components/chat/ModelPicker.tsx b/components/chat/ModelPicker.tsx index 496f5a5..4395dce 100644 --- a/components/chat/ModelPicker.tsx +++ b/components/chat/ModelPicker.tsx @@ -97,7 +97,7 @@ export function ModelPicker({ const selected = model.name === value && (model.provider ?? "ollama") === (valueProvider ?? "ollama"); const unavailable = model.available === false; return ( -
  • +