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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user