feat(dictation): PR 1.4 — draft recovery + addToHistory persistence outcome

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:36:27 +01:00
parent 6389fc2ce7
commit e3292bd597
4 changed files with 359 additions and 9 deletions

View File

@@ -24,6 +24,14 @@
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js'; import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js'; import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js';
import { errorMessage } from '$lib/utils/errors.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 prefs = getPreferences();
const tauriRuntimeAvailable = hasTauriRuntime(); const tauriRuntimeAvailable = hasTauriRuntime();
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window."; 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 // Global hotkey listener
let hotkeyHandler = () => toggleRecording(); 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<DictationDraftPayload | null>(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<typeof setTimeout> | null = null;
let visibilityHandler: (() => void) | null = null;
onMount(async () => { onMount(async () => {
window.addEventListener("kon:toggle-recording", hotkeyHandler); 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) { if (!tauriRuntimeAvailable) {
error = browserPreviewMessage; error = browserPreviewMessage;
return; return;
@@ -79,6 +120,15 @@
onDestroy(() => { onDestroy(() => {
window.removeEventListener("kon:toggle-recording", hotkeyHandler); 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); clearInterval(timerInterval);
if (page.recording) { if (page.recording) {
page.recording = false; page.recording = false;
@@ -88,6 +138,76 @@
cleanup(); 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(() => { $effect(() => {
settings.engine; settings.engine;
settings.modelSize; settings.modelSize;
@@ -192,6 +312,11 @@
end: segment.end + result.chunkStartSecs, end: segment.end + result.chunkStartSecs,
})); }));
segments = [...segments, ...absoluteSegments]; 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) { function handleLiveStatus(status) {
@@ -312,6 +437,13 @@
liveWarning = ""; liveWarning = "";
saved = false; saved = false;
transcriptionFailed = 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) { if (!tauriRuntimeAvailable) {
error = browserPreviewMessage; error = browserPreviewMessage;
page.status = "Desktop app required"; page.status = "Desktop app required";
@@ -572,7 +704,12 @@
} }
const historyId = crypto.randomUUID(); 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, id: historyId,
date: new Date().toLocaleString("en-GB"), date: new Date().toLocaleString("en-GB"),
source: "live", source: "live",
@@ -587,6 +724,16 @@
template: activeTemplate || undefined, template: activeTemplate || undefined,
audioPath, 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 // Extract tasks from transcript
const extracted = await extractTasksForTranscript(transcript); const extracted = await extractTasksForTranscript(transcript);
@@ -646,6 +793,13 @@
saved = false; saved = false;
insertPos = -1; insertPos = -1;
activeTemplate = ""; 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) { function handleExport(format) {
@@ -698,9 +852,12 @@
} }
} }
function saveTypedText() { async function saveTypedText() {
if (!transcript.trim()) return; 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(), id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"), date: new Date().toLocaleString("en-GB"),
source: "typed", source: "typed",
@@ -711,6 +868,14 @@
duration: 0, duration: 0,
language: settings.language, language: settings.language,
}); });
if (!writeResult.persisted) {
toasts.error(
"Couldn't save",
"Your transcript stays here. Try again or restart.",
);
return;
}
clearDraft();
saved = true; saved = true;
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS); setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
} }
@@ -1004,6 +1169,29 @@
</div> </div>
{/if} {/if}
<!-- PR 1.4 draft recovery banner. Inline (not modal) per the spec —
offers Restore/Discard for a draft saved within the last 24 h.
Styled to match the .error/.liveWarning notices above so it reads
as a peer affordance, not a separate dialog. -->
{#if recoverableDraft}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="px-4 py-2 rounded-lg bg-accent-subtle border border-accent/20 text-[12px] flex items-center gap-3">
<span class="text-text">
Recovered draft from {recoverableDraftAgo}
</span>
<div class="flex-1"></div>
<button
class="btn-md rounded-md text-accent hover:bg-accent/10 hover:text-accent font-medium whitespace-nowrap"
onclick={restoreDraft}
>Restore</button>
<button
class="btn-md rounded-md text-text-tertiary hover:bg-hover hover:text-text whitespace-nowrap"
onclick={discardDraft}
>Discard</button>
</div>
</div>
{/if}
{#if liveWarning && !error} {#if liveWarning && !error}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0"> <div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning"> <div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning">

View File

@@ -95,7 +95,10 @@
segments = [...segments, ...result.segments]; 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(), id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"), date: new Date().toLocaleString("en-GB"),
source: fileName, source: fileName,

View File

@@ -223,12 +223,31 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
loadHistory().catch(() => {}); loadHistory().catch(() => {});
} }
export async function addToHistory(entry: TranscriptWriteEntry) { /**
const normalised = normaliseTranscriptEntry(entry); * Result of an `addToHistory` call. `persisted: true` means the entry is
history.unshift(normalised); * safely on disk (Tauri SQLite write succeeded, or we're in the non-Tauri
if (history.length > 500) history.length = 500; * 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<AddToHistoryResult> {
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 { try {
await invoke("add_transcript", { await invoke("add_transcript", {
@@ -251,14 +270,22 @@ export async function addToHistory(entry: TranscriptWriteEntry) {
antiHallucination: !!entry.antiHallucination, 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 // Fire-and-forget auto-title. Same gate as the existing auto-cleanup
// path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in // path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in
// by the same Settings choice — no new toggle. Skipped silently if // by the same Settings choice — no new toggle. Skipped silently if
// the LLM isn't loaded; HistoryPage's per-row "Title" button is the // the LLM isn't loaded; HistoryPage's per-row "Title" button is the
// retry path. Never overwrites a title the caller already set. // retry path. Never overwrites a title the caller already set.
void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title); void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title);
return { persisted: true };
} catch (err) { } catch (err) {
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err); console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
return { persisted: false, error: err };
} }
} }

View File

@@ -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 <a few KB) and avoids a write on every mount.
*/
export function loadRecoverableDraft(
now: Date = new Date(),
maxAgeMs: number = DRAFT_RECOVERY_MAX_AGE_MS,
): DictationDraftPayload | null {
if (!canUseStorage()) return null;
const raw = localStorage.getItem(DICTATION_DRAFT_KEY);
const parsed = parseStoredJson<DictationDraftPayload>(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";
}