Files
Lumotia/src/lib/pages/DictationPage.svelte
Jake 20adf73b2a fix(dictation): PR 1.4 review polish — banner re-prompt, nav flush, language restore
- Track draft-handled-this-session flag so Restore/Discard suppresses
  the recovery banner on page-nav round-trips within one session;
  resets cleanly on the next successful save.
- Flush pending autosave in onDestroy so Tauri SPA navigation
  preserves the last <1.5s of input.
- Round-trip language on Restore so a draft captured under one
  language doesn't silently inherit the current setting on revival.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:44:21 +01:00

1314 lines
49 KiB
Svelte

<script lang="ts" module>
// PR 1.4 review polish: module-scoped (NOT component-scoped) so the
// flag survives Tauri SPA navigation re-mounting this page. Once the
// user has explicitly Restored or Discarded the recovery banner this
// session, suppress re-prompting on subsequent in-app navigations
// (Dictation → History → Dictation). Without this, the autosave that
// immediately follows a Restore would re-trigger the banner on the
// next visit. Reset to false on the next successful save (or Clear)
// so a fresh capture-and-recovery cycle still works within the same
// session without needing a full app restart.
let draftHandledThisSession = false;
</script>
<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { Channel, invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { page, settings, saveSettings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
import { markError, markGenerating, markGenerationDone, markLoading, refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js";
import Card from "$lib/components/Card.svelte";
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
import { exportTranscript } from "$lib/utils/export.js";
import { extractTasks } from "$lib/utils/taskExtractor.js";
import { pad } from "$lib/utils/time.js";
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { bionicReading } from '$lib/actions/bionicReading.js';
import { measurePreWrap } from '$lib/utils/textMeasure.js';
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.";
let transcript = $state("");
let segments = $state([]);
let timerInterval = $state(null);
let startTime = $state(0);
let modelReady = $state(false);
let modelLoading = $state(false);
let needsDownload = $state(false);
let error = $state("");
let showExportMenu = $state(false);
let transcribing = $state(false);
let saved = $state(false);
let extractedCount = $state(0);
let aiProcessing = $state(false);
let aiStatus = $state("");
let runtimeCapabilities = $state(null);
let sessionId = $state(null);
let drainingSessionId = $state(null);
let liveWarning = $state("");
let lastResultAt = $state(0);
let lastLiveActivityAt = $state(0);
let resultChannel = null;
let statusChannel = null;
// Cursor-based insertion
let textareaEl = $state(null);
let insertPos = $state(-1); // -1 = append mode, >= 0 = insert at position
// Template
let activeTemplate = $state("");
let showTemplateMenu = $state(false);
// Deduplication: track which chunk IDs have been processed
let processedChunks = new Set();
// 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). Also skipped if the
// user already Restored/Discarded this session — see
// draftHandledThisSession above.
if (!draftHandledThisSession) {
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;
}
await checkModelState();
await ensureLlmModelLoaded();
});
onDestroy(() => {
window.removeEventListener("kon:toggle-recording", hotkeyHandler);
if (visibilityHandler) {
document.removeEventListener("visibilitychange", visibilityHandler);
visibilityHandler = null;
}
window.removeEventListener("pagehide", flushAutosave);
// Tauri SPA navigation (Dictation → History → Files) doesn't fire
// pagehide or visibilitychange, so without this flush the last
// <1.5s of pre-nav input would be lost from the draft. flushAutosave
// also clears the timer, so no need to clear it again below.
flushAutosave();
clearInterval(timerInterval);
if (page.recording) {
page.recording = false;
page.status = "Ready";
page.statusColor = "#7ec89a";
}
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]
: [];
// Round-trip the language so a draft captured under a different
// language (e.g. user was in Spanish, switched away, came back)
// doesn't silently inherit the current setting on revival.
if (recoverableDraft.language) {
settings.language = recoverableDraft.language;
saveSettings();
}
recoverableDraft = null;
recoverableDraftAgo = "";
draftHandledThisSession = true;
// 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 = "";
draftHandledThisSession = true;
clearDraft();
}
$effect(() => {
settings.engine;
settings.modelSize;
if (!tauriRuntimeAvailable) return;
queueMicrotask(() => {
if (!page.recording) {
void checkModelState();
}
});
});
function whisperModelId(size) {
const map = {
Tiny: "whisper-tiny-en",
Base: "whisper-base-en",
Small: "whisper-small-en",
Medium: "whisper-medium-en",
};
return map[size] || "whisper-base-en";
}
function selectedModelId() {
return settings.engine === "parakeet"
? "parakeet-ctc-0.6b-int8"
: whisperModelId(settings.modelSize);
}
function currentEngineCapabilities() {
return runtimeCapabilities?.engines?.find((engine) => engine.id === settings.engine) || null;
}
function currentModelCapabilities() {
return currentEngineCapabilities()?.models?.find((model) => model.id === selectedModelId()) || null;
}
function currentModelIsEnglishOnly() {
return currentModelCapabilities()?.languageSupport?.kind === "english-only";
}
function effectiveLanguage() {
return currentModelIsEnglishOnly() ? "en" : settings.language;
}
async function refreshRuntimeCapabilities() {
runtimeCapabilities = await invoke("get_runtime_capabilities");
}
function matchesLiveSession(candidateSessionId) {
return candidateSessionId != null
&& (candidateSessionId === sessionId || candidateSessionId === drainingSessionId);
}
function handleLiveResult(result) {
if (!result || !matchesLiveSession(result.sessionId) || !result.segments) {
return;
}
lastResultAt = Date.now();
lastLiveActivityAt = lastResultAt;
if (textareaEl) {
if (page.recording && userScrolledUp) {
shouldPreserveScrollAnchor = true;
} else if (page.recording) {
shouldStickToBottom = true;
}
}
if (result.chunkId != null && processedChunks.has(result.chunkId)) {
return;
}
if (result.chunkId != null) processedChunks.add(result.chunkId);
// Forward raw Whisper output to the preview overlay (if it's enabled
// and opened). `raw_text` was captured in live.rs before post-processing.
if (result.rawText && settings.transcriptionPreview) {
emit("preview-append", { text: result.rawText }).catch(() => {});
}
const text = result.segments.map((segment) => segment.text).join(" ").trim();
if (text) {
if (insertPos >= 0) {
const before = transcript.slice(0, insertPos);
const after = transcript.slice(insertPos);
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
transcript = before + spaceBefore + text + spaceAfter + after;
insertPos += spaceBefore.length + text.length + spaceAfter.length;
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.selectionStart = insertPos;
textareaEl.selectionEnd = insertPos;
}
});
} else {
transcript = transcript ? `${transcript} ${text}` : text;
}
}
const absoluteSegments = result.segments.map((segment) => ({
...segment,
start: segment.start + result.chunkStartSecs,
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) {
if (!status || !matchesLiveSession(status.sessionId)) return;
lastLiveActivityAt = Date.now();
if (status.type === "overload" || status.type === "warning") {
liveWarning = status.message || "Kon is dropping older audio to stay responsive.";
return;
}
if (status.type === "error") {
error = status.message || "Live transcription failed";
transcriptionFailed = true;
page.status = "Error";
page.statusColor = "#e87171";
return;
}
if (status.type === "finished" && status.droppedAudioMs > 0) {
liveWarning = `Dropped ${Math.round(status.droppedAudioMs / 1000)}s of older audio to keep up in real time.`;
}
}
async function checkModelState() {
if (!tauriRuntimeAvailable) {
modelReady = false;
needsDownload = false;
return;
}
try {
await refreshRuntimeCapabilities();
const currentModel = currentModelCapabilities();
modelReady = !!currentModel?.loaded;
needsDownload = currentModel ? !currentModel.downloaded : true;
if (currentModelIsEnglishOnly() && settings.language !== "en") {
settings.language = "en";
}
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to check model";
}
}
async function ensureLlmModelLoaded() {
if (!tauriRuntimeAvailable || settings.aiTier === "off" || !settings.llmModelId) return;
try {
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
if (status?.downloaded && !status.loaded) {
markLoading("Loading AI model");
try {
await invoke("load_llm_model", {
modelId: settings.llmModelId,
// Sequential-GPU guard (brief item A.1 #28): frees whisper
// before loading the LLM on tight-VRAM setups. "parallel"
// keeps both resident (default, safe on multi-GB cards).
concurrent: settings.aiGpuConcurrency === "parallel",
});
} finally {
await refreshLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
}
}
} catch (err) {
console.warn("ensureLlmModelLoaded failed", err);
markError(errorMessage(err));
}
}
async function loadModel() {
if (!tauriRuntimeAvailable) {
error = browserPreviewMessage;
return;
}
modelLoading = true;
page.status = "Loading model...";
page.statusColor = "#e8c86e";
error = "";
try {
// concurrent flag mirrors settings.aiGpuConcurrency (brief A.1 #28).
const concurrent = settings.aiGpuConcurrency === "parallel";
if (settings.engine === "parakeet") {
await invoke("load_parakeet_model", { name: "ctc-int8", concurrent });
} else {
await invoke("load_model", {
size: settings.modelSize.toLowerCase(),
concurrent,
});
}
await refreshRuntimeCapabilities();
modelReady = true;
modelLoading = false;
page.status = "Ready";
page.statusColor = "#7ec89a";
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to load model";
modelLoading = false;
page.status = "Error";
page.statusColor = "#e87171";
}
}
function onModelDownloaded() {
needsDownload = false;
void loadModel();
}
async function toggleRecording() {
if (page.recording) {
await stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
error = "";
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";
page.statusColor = "#e8c86e";
return;
}
if (!modelReady) {
if (needsDownload) return;
await loadModel();
if (!modelReady) return;
}
// Capture cursor position for insert mode
if (textareaEl && transcript.length > 0) {
insertPos = textareaEl.selectionStart ?? transcript.length;
} else {
insertPos = -1; // Append mode for empty textarea
}
try {
sessionId = null;
drainingSessionId = null;
lastResultAt = 0;
lastLiveActivityAt = 0;
processedChunks.clear();
// Only clear transcript if not in insert mode (fresh recording)
if (insertPos === -1) {
transcript = "";
segments = [];
previousMeasuredContentHeight = 0;
}
resultChannel = new Channel((message) => handleLiveResult(message));
statusChannel = new Channel((message) => handleLiveStatus(message));
const response = await invoke("start_live_transcription_session", {
config: {
engine: settings.engine,
modelId: selectedModelId(),
language: effectiveLanguage(),
// Backend (Task 13) falls back to profile.initial_prompt when this
// is empty. Vocabulary now lives on the active profile, not on the
// old page.activeProfile localStorage bag.
initialPrompt: "",
profileId: profilesStore.activeProfileId,
saveAudio: settings.saveAudio,
outputFolder: settings.outputFolder || null,
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
formatMode: settings.formatMode,
// Honour Settings → Audio → Microphone choice. Empty = auto-select.
microphoneDevice: settings.microphoneDevice || null,
},
resultChannel,
statusChannel,
});
sessionId = response.sessionId;
startTime = Date.now();
page.recording = true;
page.status = "Recording...";
page.statusColor = "#e87171";
timerInterval = setInterval(updateTimer, 1000);
// Start cue (brief item #20). Off by default; volume user-set.
playStartCue(settings.soundCueVolume, settings.soundCues);
// Preview overlay: if the user opted in, reset any leftover state
// from a prior run and open the window when the main window is not
// focused (i.e. the user is dictating into some other app).
if (settings.transcriptionPreview && tauriRuntimeAvailable && !isAndroid()) {
// open_preview_window is a desktop-only multi-window command; the
// Android Tauri stub returns an error. Skip it so we don't surface
// a noisy console error every time the user starts recording on
// a mobile build.
emit("preview-listening").catch(() => {});
try {
const focused = await getCurrentWindow().isFocused();
if (!focused) {
invoke("open_preview_window").catch(() => {});
}
} catch { /* best effort */ }
}
} catch (err) {
error = typeof err === "string" ? err : err?.message || "Microphone error";
// Surface the failure as a sticky error toast so it does not get
// swallowed if the inline `error` panel is not visible.
// (Day 3 of the upgrade plan)
toasts.error("Could not start recording", error);
page.status = "Error";
page.statusColor = "#e87171";
cleanup();
}
}
async function stopRecording() {
clearInterval(timerInterval);
page.recording = false;
transcribing = true;
page.status = "Finalising...";
page.statusColor = "#e8c86e";
// Stop cue (brief item #20). "Recording over, finalising now."
playStopCue(settings.soundCueVolume, settings.soundCues);
try {
const activityBeforeStop = lastLiveActivityAt;
drainingSessionId = sessionId;
const response = sessionId
? await invoke("stop_live_transcription_session", { sessionId })
: null;
await waitForResultDrain(activityBeforeStop);
cleanup();
if (processedChunks.size === 0 && !transcript.trim()) {
page.status = "Ready";
page.statusColor = "#7ec89a";
} else {
await finaliseTranscription(response?.audioPath || null);
}
} catch (err) {
error = typeof err === "string" ? err : err?.message || "Failed to stop live transcription";
page.status = "Error";
page.statusColor = "#e87171";
cleanup();
} finally {
transcribing = false;
}
}
function cleanup() {
sessionId = null;
drainingSessionId = null;
resultChannel = null;
statusChannel = null;
}
function replaceSegmentsWithCleanedText(cleanedText) {
if (!cleanedText.trim()) return;
if (segments.length === 0) {
segments = [{
start: 0,
end: Math.max((Date.now() - startTime) / 1000, 0),
text: cleanedText,
}];
return;
}
segments = [{
start: segments[0].start,
end: segments[segments.length - 1].end,
text: cleanedText,
}];
}
async function cleanupTranscriptIfEnabled(text) {
if (!text.trim()) return text;
if (settings.aiTier === "off" || settings.formatMode === "Raw") return text;
const llmLoaded = await invoke("get_llm_status").catch(() => false);
if (!llmLoaded) return text;
// Flip the sidebar LLM status chip to "generating" so the user can
// see the cleanup pass in flight (brief item #31). Always paired
// with markGenerationDone below — success or failure.
markGenerating("Cleaning up");
try {
const cleaned = await invoke("cleanup_transcript_text_cmd", {
transcript: text,
profileId: profilesStore.activeProfileId,
preset: settings.llmPromptPreset,
});
markGenerationDone(true);
return cleaned?.trim() ? cleaned.trim() : text;
} catch (err) {
console.warn("LLM cleanup failed, keeping existing transcript", err);
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
return text;
}
}
async function extractTasksForTranscript(text) {
const llmLoaded = await invoke("get_llm_status").catch(() => false);
if (settings.aiTier === "tasks" && llmLoaded) {
markGenerating("Extracting tasks");
try {
const items = await invoke("extract_tasks_from_transcript_cmd", {
transcript: text,
profileId: profilesStore.activeProfileId,
});
markGenerationDone(true);
return items.map((taskText) => ({ text: taskText }));
} catch (err) {
console.warn("LLM extract_tasks failed, falling back to regex", err);
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
}
}
return extractTasks(text);
}
async function waitForResultDrain(previousActivityAt = 0) {
const firstMessageDeadline = Date.now() + 400;
while (Date.now() < firstMessageDeadline) {
if (lastLiveActivityAt > previousActivityAt) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (lastLiveActivityAt <= previousActivityAt) {
return;
}
const idleDeadline = Date.now() + 1500;
while (Date.now() < idleDeadline) {
if (Date.now() - lastLiveActivityAt >= 200) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
async function finaliseTranscription(audioPath = null) {
if (transcript.trim()) {
if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") {
emit("preview-cleanup").catch(() => {});
}
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
if (cleanedTranscript !== transcript) {
transcript = cleanedTranscript;
replaceSegmentsWithCleanedText(cleanedTranscript);
}
if (settings.transcriptionPreview) {
emit("preview-final", { text: transcript }).catch(() => {});
}
if (settings.autoPaste) {
try {
const outcome = await invoke("paste_text", { text: transcript });
if (!outcome?.pasted && outcome?.message) {
toasts.warn(
"Auto-paste unavailable",
`${outcome.message}${outcome.copied ? ". Transcript is on your clipboard." : ""}`,
);
}
} catch (err) {
await navigator.clipboard
.writeText(transcript)
.catch(() => invoke("copy_to_clipboard", { text: transcript }).catch(() => {}));
toasts.warn("Auto-paste failed", String(err));
}
} else if (settings.autoCopy) {
navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
});
}
const historyId = crypto.randomUUID();
// 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",
profileId: profilesStore.activeProfileId,
preview: transcript.slice(0, 120),
text: transcript,
segments: segments,
duration: (Date.now() - startTime) / 1000,
engine: settings.engine,
modelId: selectedModelId(),
language: effectiveLanguage(),
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();
// Successful save closes the current draft cycle; clear the
// session flag so a fresh capture-and-recovery cycle works
// normally without needing a full app restart.
draftHandledThisSession = false;
// Extract tasks from transcript
const extracted = await extractTasksForTranscript(transcript);
extractedCount = extracted.length;
for (const item of extracted) {
addTask({
text: item.text,
bucket: "inbox",
sourceTranscriptId: historyId,
});
}
// Auto-open task sidebar if tasks were extracted
if (extracted.length > 0) {
page.taskSidebarOpen = true;
}
// Complete cue (brief item #20) — major-third arpeggio. Plays
// after the full finalise flow so the user hears "done"
// aligned with the history entry + paste landing.
playCompleteCue(settings.soundCueVolume, settings.soundCues);
saved = true;
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
} else if (settings.transcriptionPreview) {
// No transcript to surface — dismiss the overlay rather than leaving
// it stuck in "listening".
emit("preview-hide").catch(() => {});
}
page.status = "Ready";
page.statusColor = "#7ec89a";
insertPos = -1;
}
function updateTimer() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
page.timerText = `${pad(Math.floor(elapsed / 60))}:${pad(elapsed % 60)}`;
}
function copyAll() {
if (transcript) {
navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
});
}
}
function clearTranscript() {
transcript = "";
segments = [];
previousMeasuredContentHeight = 0;
shouldPreserveScrollAnchor = false;
shouldStickToBottom = false;
page.timerText = "00:00";
error = "";
liveWarning = "";
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 = "";
draftHandledThisSession = false;
clearDraft();
}
function handleExport(format) {
if (!transcript) return;
showExportMenu = false;
const content = exportTranscript(transcript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt";
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `kon-${new Date().toISOString().slice(0, 10)}.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
function applyTemplate(template) {
activeTemplate = template.name;
showTemplateMenu = false;
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
segments = [];
previousMeasuredContentHeight = 0;
// Position cursor at first section body
requestAnimationFrame(() => {
if (textareaEl) {
const firstNewline = transcript.indexOf("\n\n") + 2;
textareaEl.selectionStart = firstNewline;
textareaEl.selectionEnd = firstNewline;
textareaEl.focus();
}
});
}
async function manualExtractTasks() {
if (!transcript.trim() || aiProcessing) return;
aiProcessing = true;
aiStatus = "Extracting tasks...";
try {
const extracted = await extractTasksForTranscript(transcript);
for (const item of extracted) {
addTask({ text: item.text, bucket: "inbox" });
}
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
} catch (err) {
aiStatus = typeof err === "string" ? err : "Extraction failed";
} finally {
aiProcessing = false;
setTimeout(() => { aiStatus = ""; }, 4000);
}
}
async function saveTypedText() {
if (!transcript.trim()) return;
// 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",
profileId: profilesStore.activeProfileId,
preview: transcript.slice(0, 120),
text: transcript,
segments: [],
duration: 0,
language: settings.language,
});
if (!writeResult.persisted) {
toasts.error(
"Couldn't save",
"Your transcript stays here. Try again or restart.",
);
return;
}
clearDraft();
draftHandledThisSession = false;
saved = true;
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
}
let taskCount = $derived(tasks.filter((t) => !t.done).length);
let wordCount = $derived.by(() => {
const trimmed = transcript.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
});
// Pretext content height measurement (DOM-free).
// Used for scroll-to-bottom affordance during live transcription.
let textareaWidth = $state(0);
let userScrolledUp = $state(false);
let previousMeasuredContentHeight = $state(0);
let shouldPreserveScrollAnchor = $state(false);
let shouldStickToBottom = $state(false);
// Build font string matching .font-transcript CSS
let transcriptFont = $derived(transcriptPretextFont(prefs.accessibility));
let transcriptLineHeight = $derived(transcriptPretextLineHeight(prefs.accessibility));
let contentHeight = $derived.by(() => {
if (!transcript.trim() || textareaWidth <= 0) return 0;
// Subtract padding (p-6 = 24px each side)
const innerWidth = textareaWidth - 48;
if (innerWidth <= 0) return 0;
return measurePreWrap(transcript, transcriptFont, innerWidth, transcriptLineHeight).height;
});
function onTextareaScroll(e) {
const el = e.target;
// "scrolled up" = more than 1 line from the bottom
userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > transcriptLineHeight;
}
function scrollToBottom() {
if (textareaEl) {
textareaEl.scrollTop = textareaEl.scrollHeight;
userScrolledUp = false;
}
}
// Track textarea width for Pretext layout calculations
$effect(() => {
if (!textareaEl) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) textareaWidth = entry.contentRect.width;
});
ro.observe(textareaEl);
return () => ro.disconnect();
});
$effect(() => {
const measuredHeight = contentHeight;
const previousHeight = previousMeasuredContentHeight;
if (!textareaEl) {
previousMeasuredContentHeight = measuredHeight;
return;
}
if (shouldPreserveScrollAnchor && measuredHeight > previousHeight) {
const delta = measuredHeight - previousHeight;
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.scrollTop += delta;
}
});
shouldPreserveScrollAnchor = false;
} else if (shouldStickToBottom) {
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.scrollTop = textareaEl.scrollHeight;
}
});
shouldStickToBottom = false;
}
previousMeasuredContentHeight = measuredHeight;
});
let showScrollHint = $derived(page.recording && userScrolledUp && contentHeight > 0);
let reduceMotion = $derived(
prefs.accessibility.reduceMotion === 'on'
|| (prefs.accessibility.reduceMotion === 'system'
&& typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches)
);
let transcriptionFailed = $state(false);
</script>
<div class="flex flex-col h-full animate-fade-in">
<div aria-live="assertive" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);">
{#if page.recording}Recording started{/if}
</div>
{#if needsDownload}
<ModelDownloader
engine={settings.engine}
modelSize={settings.modelSize.toLowerCase()}
onComplete={onModelDownloaded}
/>
{:else}
<!-- Control strip -->
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
<!-- Record button -->
<button
class="relative flex items-center justify-center w-[40px] h-[40px] min-w-[40px] flex-shrink-0 rounded-full text-white
active:scale-[0.93] transition-all
{page.recording
? 'bg-danger animate-pulse-warm'
: modelLoading
? 'bg-warning opacity-60 cursor-wait'
: !tauriRuntimeAvailable
? 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
: 'bg-accent hover:bg-accent-hover shadow-[0_4px_20px_rgba(232,168,124,0.3)]'}"
onclick={toggleRecording}
disabled={modelLoading || !tauriRuntimeAvailable}
aria-label={page.recording ? "Stop recording" : "Start recording"}
style="transition-duration: var(--duration-ui)"
>
{#if page.recording}
<span class="w-[14px] h-[14px] rounded-[3px] bg-white"></span>
{:else if modelLoading}
<Loader2 size={16} class="animate-spin" aria-hidden="true" />
{:else}
<span class="w-[14px] h-[14px] rounded-full bg-white/90"></span>
{/if}
</button>
<span class="text-[11px] font-medium flex-shrink-0 {page.recording ? 'text-danger' : 'text-text-secondary'}">
{page.recording ? 'Stop' : modelLoading ? 'Loading' : 'Record'}
</span>
<!-- Waveform placeholder (Phase 2: live visualiser) -->
<div class="flex-1 min-h-[32px] rounded-md border border-border-subtle bg-bg-elevated/40 flex items-center px-3">
{#if page.recording}
<span class="flex items-end gap-[3px] h-[18px]">
{#each [0.4, 0.8, 0.5, 1, 0.6, 0.9, 0.4, 0.7, 0.5, 0.8, 0.3, 0.6] as h, i}
{#if reduceMotion}
<span
class="w-[2px] rounded-full bg-danger"
style="height: 50%"
></span>
{:else}
<span
class="w-[2px] rounded-full bg-danger/70 animate-pulse-soft"
style="height: {h * 100}%; animation-delay: {i * 60}ms"
></span>
{/if}
{/each}
</span>
{:else}
<span class="text-[11px] text-text-tertiary">
{#if modelLoading}
Loading model...
{:else if !tauriRuntimeAvailable}
Desktop app required for local transcription
{:else if saved}
<span class="text-success animate-fade-in">
Saved{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
</span>
{:else}
Press record or <kbd class="px-1 py-0.5 rounded bg-bg-elevated text-[10px] text-text-tertiary border border-border-subtle">{settings.globalHotkey}</kbd>
{/if}
</span>
{/if}
</div>
<!-- Timer -->
<span class="text-[20px] font-bold text-text tabular-nums tracking-tight leading-none flex-shrink-0" style="font-variant-numeric: tabular-nums;">
{page.timerText}
</span>
<!-- Status indicator -->
<span class="text-[11px] text-text-tertiary flex-shrink-0 min-w-[60px] text-right">
{#if page.recording}
<span class="inline-flex items-center gap-1.5">
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
<span class="font-medium text-danger tracking-wide">REC</span>
</span>
{:else if modelLoading}
<span class="text-warning">Loading</span>
{:else}
<span class="text-success">Ready</span>
{/if}
</span>
<!-- Separator -->
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
<!-- Task sidebar toggle -->
<button
class="relative px-2 py-1.5 rounded-lg flex-shrink-0
{page.taskSidebarOpen ? 'bg-accent/10' : 'hover:bg-hover'}"
onclick={() => page.taskSidebarOpen = !page.taskSidebarOpen}
aria-label="Toggle task sidebar"
>
<span class="flex items-center gap-1.5">
<SquareCheck size={16} class={page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'} aria-hidden="true" />
<span class="text-[11px] {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}">Tasks</span>
</span>
{#if taskCount > 0}
<span class="absolute -top-0.5 -right-0.5 text-[9px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
{taskCount}
</span>
{/if}
</button>
</div>
<!-- Toolbar -->
<div class="flex items-center justify-end gap-1 px-5 py-2 border-b border-border-subtle flex-shrink-0">
<!-- Template selector -->
{#if templates.length > 0}
<div class="relative">
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={() => { showTemplateMenu = !showTemplateMenu; showExportMenu = false; }}
>Template</button>
{#if showTemplateMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[160px]">
{#each templates as template}
<button
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text
{activeTemplate === template.name ? 'text-accent' : ''}"
onclick={() => applyTemplate(template)}
>{template.name}</button>
{/each}
</div>
{/if}
</div>
{/if}
<button
class="btn-md rounded-lg flex-shrink-0 whitespace-nowrap {aiProcessing ? 'text-warning' : 'text-accent hover:bg-accent/10 hover:text-accent font-medium'}"
onclick={manualExtractTasks}
disabled={aiProcessing || !transcript.trim()}
>{aiProcessing ? "Extracting..." : "Extract Tasks"}</button>
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap
{!transcript.trim() ? 'opacity-40 cursor-default' : ''}"
onclick={saveTypedText}
disabled={!transcript.trim()}
>Save</button>
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={copyAll}
>Copy</button>
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={clearTranscript}
>Clear</button>
<div class="relative flex-shrink-0">
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text whitespace-nowrap"
onclick={() => { showExportMenu = !showExportMenu; showTemplateMenu = false; }}
>Export</button>
{#if showExportMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
<button
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
onclick={() => handleExport(fmt)}
>{label}</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Template indicator -->
{#if activeTemplate}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-accent-subtle border border-accent/20">
<span class="text-[11px] text-accent font-medium">Template: {activeTemplate}</span>
<span class="text-[11px] text-text-tertiary">Click a section, then record to fill it</span>
<div class="flex-1"></div>
<button class="text-[11px] text-text-tertiary hover:text-text" onclick={() => activeTemplate = ""}>Remove</button>
</div>
</div>
{/if}
<!-- Error -->
{#if error}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
{error}
</div>
</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">
{liveWarning}
</div>
</div>
{/if}
<!-- Transcript area -->
<div class="flex-1 px-5 pt-3 pb-3 min-h-0" style="--text-transcript: {prefs.accessibility.transcriptSize}px; font-size: var(--text-transcript)">
<Card classes="h-full flex flex-col">
{#if transcriptionFailed && !transcript.trim() && !page.recording}
<div class="flex-1 flex items-center justify-center">
<EmptyState icon={AlertTriangle} message="Something went wrong with that transcription. Your audio is saved try again when you're ready." />
</div>
{:else if !transcript.trim() && !page.recording && !transcribing}
<div class="flex-1 flex items-center justify-center">
<EmptyState icon={Mic} message={`Press record or ${settings.globalHotkey}`} />
</div>
<textarea
bind:this={textareaEl}
class="font-transcript flex-1 w-full bg-transparent text-text p-6
resize-none focus:outline-none placeholder:text-text-tertiary min-h-0 hidden"
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
bind:value={transcript}
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
data-no-transition
aria-label="Transcript"
aria-live="polite"
></textarea>
{:else}
<div class="relative flex-1 min-h-0">
<textarea
bind:this={textareaEl}
class="font-transcript h-full w-full bg-transparent text-text p-6
resize-none focus:outline-none placeholder:text-text-tertiary"
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
bind:value={transcript}
onscroll={onTextareaScroll}
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
data-no-transition
aria-label="Transcript"
aria-live="polite"
></textarea>
{#if showScrollHint}
<button
class="absolute bottom-3 right-6 px-3 py-1.5 rounded-full bg-accent text-white text-[11px] font-medium shadow-md hover:bg-accent-hover animate-fade-in"
onclick={scrollToBottom}
aria-label="Scroll to latest"
style="transition-duration: var(--duration-ui)"
>
↓ New text
</button>
{/if}
</div>
{/if}
<!-- Status footer (inside transcript card) -->
<div class="flex items-center justify-between px-6 pb-3 pt-1 flex-shrink-0">
<span class="text-[11px] text-text-tertiary">
{#if wordCount > 0}
{wordCount} {wordCount === 1 ? 'word' : 'words'}
{:else}
0 words
{/if}
{#if insertPos >= 0 && page.recording}
· <span class="text-accent">inserting at cursor</span>
{/if}
</span>
<div class="flex items-center gap-2">
{#if aiStatus}
<span class="text-[11px] text-accent animate-fade-in">{aiStatus}</span>
<span class="text-[11px] text-text-tertiary">·</span>
{/if}
<span class="text-[11px] text-text-tertiary">
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
</span>
{#if transcript.trim()}
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
{/if}
</div>
</div>
</Card>
</div>
{/if}
</div>