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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -223,12 +223,31 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
|
||||
loadHistory().catch(() => {});
|
||||
}
|
||||
|
||||
export async function addToHistory(entry: TranscriptWriteEntry) {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!hasTauriRuntime()) return;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
132
src/lib/utils/dictationDraft.ts
Normal file
132
src/lib/utils/dictationDraft.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user