From e3292bd5972a844918217d50dd49d838c95a8c7a Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 26 Apr 2026 18:36:27 +0100 Subject: [PATCH] =?UTF-8?q?feat(dictation):=20PR=201.4=20=E2=80=94=20draft?= =?UTF-8?q?=20recovery=20+=20addToHistory=20persistence=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addToHistory previously swallowed SQLite write failures with a console warning while still pushing the entry into the in-memory history store — a silent data-loss bug, since the row vanished on next restart with no signal to the user. It now returns { persisted, error }, only mutates in-memory history after the disk write lands, and the dictation flow awaits the result so it can surface a "Couldn't save" toast and keep the user's content intact for a retry. The new dictation autosave writes transcript + segments to localStorage on a 1.5s debounce, on every segment boundary, and on visibilitychange/pagehide — covering both live capture and the stopped-but-unsaved walk-away case. On DictationPage mount any draft <24h old surfaces an inline Restore/Discard banner that styles as a peer to the existing error notices. The draft is cleared only after a successful SQLite write or explicit Discard/Clear; on persistence failure it stays on disk so the user can recover on relaunch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/pages/DictationPage.svelte | 194 ++++++++++++++++++++++++++++- src/lib/pages/FilesPage.svelte | 5 +- src/lib/stores/page.svelte.ts | 37 +++++- src/lib/utils/dictationDraft.ts | 132 ++++++++++++++++++++ 4 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 src/lib/utils/dictationDraft.ts diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index ca8d9c1..6c5cc68 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -24,6 +24,14 @@ import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js'; import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js'; import { errorMessage } from '$lib/utils/errors.js'; + import { + buildDraftPayload, + clearDraft, + formatTimeAgo, + loadRecoverableDraft, + saveDraft, + type DictationDraftPayload, + } from '$lib/utils/dictationDraft.js'; const prefs = getPreferences(); const tauriRuntimeAvailable = hasTauriRuntime(); const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window."; @@ -65,9 +73,42 @@ // Global hotkey listener let hotkeyHandler = () => toggleRecording(); + // PR 1.4 draft recovery — surfaces an inline banner on mount when a + // <24h-old draft is in localStorage. The actual recover/discard wiring + // lives in restoreDraft() / discardDraft() below; this state just + // drives the banner render. + let recoverableDraft = $state(null); + let recoverableDraftAgo = $state(""); + // Debounce handle for the autosave $effect. Stored at module scope so + // we can clear-and-reschedule on each transcript/segment change without + // accumulating timers. visibilitychange flushes it eagerly. + let autosaveTimer: ReturnType | null = null; + let visibilityHandler: (() => void) | null = null; + onMount(async () => { window.addEventListener("kon:toggle-recording", hotkeyHandler); + // Recovery check — runs even in non-Tauri preview so the browser + // shell can also offer to restore. Only shows the banner if there's + // nothing currently in the textarea (otherwise the user has live + // content and a banner would be confusing). + const draft = loadRecoverableDraft(); + if (draft && !transcript.trim()) { + recoverableDraft = draft; + recoverableDraftAgo = formatTimeAgo(draft.updatedAt); + } + + // visibilitychange + pagehide flush the autosave immediately so + // hiding the window or closing the app captures the latest state + // without waiting for the 1.5s debounce. + visibilityHandler = () => { + if (document.visibilityState === "hidden") { + flushAutosave(); + } + }; + document.addEventListener("visibilitychange", visibilityHandler); + window.addEventListener("pagehide", flushAutosave); + if (!tauriRuntimeAvailable) { error = browserPreviewMessage; return; @@ -79,6 +120,15 @@ onDestroy(() => { window.removeEventListener("kon:toggle-recording", hotkeyHandler); + if (visibilityHandler) { + document.removeEventListener("visibilitychange", visibilityHandler); + visibilityHandler = null; + } + window.removeEventListener("pagehide", flushAutosave); + if (autosaveTimer) { + clearTimeout(autosaveTimer); + autosaveTimer = null; + } clearInterval(timerInterval); if (page.recording) { page.recording = false; @@ -88,6 +138,76 @@ cleanup(); }); + /** + * Synchronously persist the current dictation state to localStorage. + * Called on visibilitychange/pagehide and on segment boundaries — paths + * where we can't afford to wait for the 1.5s debounce. No-op when the + * transcript is empty: we deliberately do NOT clear the draft here, + * because "transcript happens to be empty right now" is not the same + * as "user wants to discard". The explicit clear paths are (a) a + * successful save, (b) the Discard button, and (c) the Clear button. + */ + function flushAutosave() { + if (autosaveTimer) { + clearTimeout(autosaveTimer); + autosaveTimer = null; + } + const payload = buildDraftPayload(transcript, segments, effectiveLanguage()); + if (payload) saveDraft(payload); + } + + /** + * PR 1.4 autosave — debounced 1.5s on any transcript or segments + * change. Reading both reactive references in the $effect causes + * Svelte 5 to re-run on either change. Empty transcript + no segments + * is a no-op (no save, no clear) so the on-mount empty state can't + * destroy the very draft we just loaded for the recovery banner; the + * user has to explicitly Save / Discard / Clear to drop the draft. + */ + $effect(() => { + // Read reactive deps explicitly so the effect re-runs on either. + const t = transcript; + const segCount = segments.length; + if (autosaveTimer) { + clearTimeout(autosaveTimer); + autosaveTimer = null; + } + if (!t.trim() && segCount === 0) return; + // Once content lands the recovery banner is misleading: clicking + // Restore would blow away the in-progress work. Dismiss the banner + // (but do NOT clearDraft — the autosave that's about to fire below + // will overwrite localStorage with the new content anyway). + if (recoverableDraft) { + recoverableDraft = null; + recoverableDraftAgo = ""; + } + autosaveTimer = setTimeout(() => { + autosaveTimer = null; + const payload = buildDraftPayload(transcript, segments, effectiveLanguage()); + if (payload) saveDraft(payload); + }, 1500); + }); + + function restoreDraft() { + if (!recoverableDraft) return; + transcript = recoverableDraft.transcript; + segments = Array.isArray(recoverableDraft.segments) + ? [...recoverableDraft.segments] + : []; + recoverableDraft = null; + recoverableDraftAgo = ""; + // Don't clear the localStorage entry — the autosave $effect will + // immediately rewrite it with a fresh `updatedAt` reflecting the + // restored state. If the app crashes between restore and next + // autosave the draft is still there. + } + + function discardDraft() { + recoverableDraft = null; + recoverableDraftAgo = ""; + clearDraft(); + } + $effect(() => { settings.engine; settings.modelSize; @@ -192,6 +312,11 @@ end: segment.end + result.chunkStartSecs, })); segments = [...segments, ...absoluteSegments]; + // PR 1.4: each new segment chunk is a natural checkpoint — flush + // the draft immediately rather than waiting for the 1.5s debounce. + // This is the strongest guarantee we can give for "user pulled the + // plug mid-sentence" scenarios. + if (absoluteSegments.length > 0) flushAutosave(); } function handleLiveStatus(status) { @@ -312,6 +437,13 @@ liveWarning = ""; saved = false; transcriptionFailed = false; + // PR 1.4: starting a new recording is implicit "I'm working on + // something new" — drop the recovery banner so the user doesn't + // accidentally Restore over their fresh capture. We do NOT call + // clearDraft() here: the autosave will overwrite the localStorage + // entry with the new content on the first chunk anyway. + recoverableDraft = null; + recoverableDraftAgo = ""; if (!tauriRuntimeAvailable) { error = browserPreviewMessage; page.status = "Desktop app required"; @@ -572,7 +704,12 @@ } const historyId = crypto.randomUUID(); - addToHistory({ + // PR 1.4: await so we can gate draft-clearing on the SQLite write + // outcome. On `persisted: false` we keep the draft + textarea so + // the user can retry; on success we clear the draft (the row is + // safely on disk and the recovery banner would be misleading on + // next launch). + const writeResult = await addToHistory({ id: historyId, date: new Date().toLocaleString("en-GB"), source: "live", @@ -587,6 +724,16 @@ template: activeTemplate || undefined, audioPath, }); + if (!writeResult.persisted) { + toasts.error( + "Couldn't save", + "Your transcript stays here. Try again or restart.", + ); + page.status = "Error"; + page.statusColor = "#e87171"; + return; + } + clearDraft(); // Extract tasks from transcript const extracted = await extractTasksForTranscript(transcript); @@ -646,6 +793,13 @@ saved = false; insertPos = -1; activeTemplate = ""; + // PR 1.4: explicit user discard. Drop the recovery banner if it + // was still visible AND drop the localStorage draft so we don't + // re-prompt on next launch with the work the user just chose to + // throw away. + recoverableDraft = null; + recoverableDraftAgo = ""; + clearDraft(); } function handleExport(format) { @@ -698,9 +852,12 @@ } } - function saveTypedText() { + async function saveTypedText() { if (!transcript.trim()) return; - addToHistory({ + // PR 1.4: same gate as the live path. Typed text is just as + // recoverable as captured text — the autosave $effect already has + // it in localStorage; we just need to keep it there on failure. + const writeResult = await addToHistory({ id: crypto.randomUUID(), date: new Date().toLocaleString("en-GB"), source: "typed", @@ -711,6 +868,14 @@ duration: 0, language: settings.language, }); + if (!writeResult.persisted) { + toasts.error( + "Couldn't save", + "Your transcript stays here. Try again or restart.", + ); + return; + } + clearDraft(); saved = true; setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS); } @@ -1004,6 +1169,29 @@ {/if} + + {#if recoverableDraft} +
+
+ + Recovered draft from {recoverableDraftAgo} + +
+ + +
+
+ {/if} + {#if liveWarning && !error}
diff --git a/src/lib/pages/FilesPage.svelte b/src/lib/pages/FilesPage.svelte index 70084e4..e1acfe1 100644 --- a/src/lib/pages/FilesPage.svelte +++ b/src/lib/pages/FilesPage.svelte @@ -95,7 +95,10 @@ segments = [...segments, ...result.segments]; - addToHistory({ + // Fire-and-forget: file transcription is a batch flow, the user + // is watching the progress bar, not the history list. If the + // write fails the user can re-drop the file. + void addToHistory({ id: crypto.randomUUID(), date: new Date().toLocaleString("en-GB"), source: fileName, diff --git a/src/lib/stores/page.svelte.ts b/src/lib/stores/page.svelte.ts index 202b6c1..d4768d0 100644 --- a/src/lib/stores/page.svelte.ts +++ b/src/lib/stores/page.svelte.ts @@ -223,12 +223,31 @@ if (typeof window !== "undefined" && hasTauriRuntime()) { loadHistory().catch(() => {}); } -export async function addToHistory(entry: TranscriptWriteEntry) { - const normalised = normaliseTranscriptEntry(entry); - history.unshift(normalised); - if (history.length > 500) history.length = 500; +/** + * Result of an `addToHistory` call. `persisted: true` means the entry is + * safely on disk (Tauri SQLite write succeeded, or we're in the non-Tauri + * shell where there is no disk). `persisted: false` means the in-memory + * history was NOT updated either — the caller is responsible for keeping + * the user's source content (e.g. the dictation draft) alive so a retry + * is possible. Surfacing this is what closes the historical silent-loss + * bug where SQLite failures were swallowed by `console.warn`. + */ +export interface AddToHistoryResult { + persisted: boolean; + error?: unknown; +} - if (!hasTauriRuntime()) return; +export async function addToHistory(entry: TranscriptWriteEntry): Promise { + const normalised = normaliseTranscriptEntry(entry); + + // Non-Tauri shell (browser preview, SSR test): no disk to fail. Update + // the in-memory store and report success so callers don't think the + // write was lost — there was no write to lose. + if (!hasTauriRuntime()) { + history.unshift(normalised); + if (history.length > 500) history.length = 500; + return { persisted: true }; + } try { await invoke("add_transcript", { @@ -251,14 +270,22 @@ export async function addToHistory(entry: TranscriptWriteEntry) { antiHallucination: !!entry.antiHallucination, }, }); + // Only mutate the in-memory history after the disk write lands. + // Adding to memory while the disk write fails is exactly the misleading + // state PR 1.4 set out to fix — the row would appear in History, then + // silently vanish on next launch. + history.unshift(normalised); + if (history.length > 500) history.length = 500; // Fire-and-forget auto-title. Same gate as the existing auto-cleanup // path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in // by the same Settings choice — no new toggle. Skipped silently if // the LLM isn't loaded; HistoryPage's per-row "Title" button is the // retry path. Never overwrites a title the caller already set. void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title); + return { persisted: true }; } catch (err) { console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err); + return { persisted: false, error: err }; } } diff --git a/src/lib/utils/dictationDraft.ts b/src/lib/utils/dictationDraft.ts new file mode 100644 index 0000000..a89185d --- /dev/null +++ b/src/lib/utils/dictationDraft.ts @@ -0,0 +1,132 @@ +// Draft autosave/recovery for the Dictation page (PR 1.4). +// +// Covers two failure modes that previously lost user work: +// 1. App or window closes mid-capture before the user hits Stop. +// 2. User stops, walks away, app exits before the SQLite write +// surfaces its outcome — or the SQLite write fails outright. +// +// The payload is intentionally tiny — transcript + segments + language +// — so the localStorage write stays cheap on every keystroke. The +// autosave call site debounces to 1.5s; this module just owns the +// shape and the parse/serialise round trip. + +import type { Segment } from "$lib/types/app"; +import { parseStoredJson } from "$lib/utils/storage.js"; + +export const DICTATION_DRAFT_KEY = "kon.dictation.draft.v1"; + +/** 24 hours in ms — drafts older than this are ignored on mount. */ +export const DRAFT_RECOVERY_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +export interface DictationDraftPayload { + /** ISO-8601 string. Compared to Date.now() to gate the recovery banner. */ + updatedAt: string; + transcript: string; + segments: Segment[]; + language?: string; +} + +/** + * Build a draft payload from current dictation state. Returns `null` if + * there is nothing worth persisting (empty transcript and no segments). + * The caller is expected to call `clearDraft()` instead when this returns + * null, so we don't pollute localStorage with empty drafts. + */ +export function buildDraftPayload( + transcript: string, + segments: Segment[], + language: string | undefined, + now: Date = new Date(), +): DictationDraftPayload | null { + const hasTranscript = transcript.trim().length > 0; + const hasSegments = Array.isArray(segments) && segments.length > 0; + if (!hasTranscript && !hasSegments) return null; + + return { + updatedAt: now.toISOString(), + transcript, + segments: Array.isArray(segments) ? segments : [], + ...(language ? { language } : {}), + }; +} + +function canUseStorage(): boolean { + return typeof localStorage !== "undefined"; +} + +export function saveDraft(payload: DictationDraftPayload): void { + if (!canUseStorage()) return; + try { + localStorage.setItem(DICTATION_DRAFT_KEY, JSON.stringify(payload)); + } catch { + // Quota exceeded or storage disabled — silently drop. The user still + // has the in-memory transcript; we just lose the recover-on-relaunch + // safety net for this run. No toast; the failure mode is rare and + // surfacing it would confuse more than it helps. + } +} + +export function clearDraft(): void { + if (!canUseStorage()) return; + try { + localStorage.removeItem(DICTATION_DRAFT_KEY); + } catch { + /* same rationale as saveDraft */ + } +} + +/** + * Read the persisted draft, returning it only if it exists, parses + * cleanly, and is fresher than `maxAgeMs` (default 24 h). Anything older + * is treated as stale and the caller can show no banner. + * + * We do NOT auto-prune the stale entry here — leaving it in place is + * harmless (it's still (raw); + if (!parsed || typeof parsed.updatedAt !== "string") return null; + + const updatedAtMs = Date.parse(parsed.updatedAt); + if (!Number.isFinite(updatedAtMs)) return null; + if (now.getTime() - updatedAtMs > maxAgeMs) return null; + + // Defensive: if the stored draft has neither transcript nor segments + // (shouldn't happen because saveDraft refuses empty payloads, but a + // hand-edited or truncated entry could trip this), treat as no draft. + const hasTranscript = typeof parsed.transcript === "string" && parsed.transcript.trim().length > 0; + const hasSegments = Array.isArray(parsed.segments) && parsed.segments.length > 0; + if (!hasTranscript && !hasSegments) return null; + + return { + updatedAt: parsed.updatedAt, + transcript: typeof parsed.transcript === "string" ? parsed.transcript : "", + segments: Array.isArray(parsed.segments) ? parsed.segments : [], + ...(typeof parsed.language === "string" ? { language: parsed.language } : {}), + }; +} + +/** + * Coarse "x ago" formatter for the recovery banner. Intentionally short + * — we only need enough resolution for the user to recognise their own + * recent work ("3 minutes ago", "2 hours ago"). Returns "just now" for + * <30s and clamps to "yesterday" for things ~24h old (the recovery cap). + */ +export function formatTimeAgo(iso: string, now: Date = new Date()): string { + const then = Date.parse(iso); + if (!Number.isFinite(then)) return ""; + const deltaMs = Math.max(0, now.getTime() - then); + const seconds = Math.floor(deltaMs / 1000); + if (seconds < 30) return "just now"; + if (seconds < 60) return `${seconds} seconds ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; + return "yesterday"; +}