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 { 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<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 () => {
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 @@
</div>
{/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}
<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">