From ce849a15ab4484fc4b2d0b4c5d25e8947890db7c Mon Sep 17 00:00:00 2001 From: Jake Date: Sat, 25 Apr 2026 19:48:29 +0100 Subject: [PATCH] feat(auto-title): auto on save + per-row + bulk in History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire generate_title_cmd into the user-visible flows. Three entry points, matching the agreed hybrid trigger (auto when conditions are met, plus on-demand for retroactive titling of old transcripts): 1. Auto on save — addToHistory in src/lib/stores/page.svelte.ts now fires `maybeAutoGenerateTitle` fire-and-forget after the SQLite write succeeds. Gate: `aiTier !== "off" && formatMode !== "Raw"`, piggybacking on the same Settings choice that drives auto-cleanup. Per the design principle "Every new setting must earn its mental real estate" (README.md:22), no new settings flag. Skipped silently when LLM isn't loaded; user retries via the per-row button. Never overwrites a title the caller already set. 2. Per-row "Title" button in HistoryPage — mirrors the existing Tag button shape exactly (Sparkles icon vs Tag icon). In-flight tracked via a `titling: Set` so the same row can't fire twice; the row stays disabled with "Titling…" copy while in flight. Persists via the existing `renameHistoryEntry` (which already calls `update_transcript`) — no need to extend `saveTranscriptMeta`. 3. Bulk "Title all untitled" toolbar action — same shape as "Tag all untagged". Filters `history` to entries where `!item.title || !item.title.trim()`, iterates with progress text, surfaces a success toast at the end with the count actually written (not iterated — empty model responses count as skipped). Visible with no schema changes, no migration: transcripts.title has been nullable since v1 (migrations.rs:12-29). Verification: - `npm run check`: 0 errors, 0 warnings across 3957 files. - `cargo test --workspace --lib`: 280 passing (was 277; +3 from the sanitize_title tests landed in the kon-llm commit). Together with the prior two commits this closes the "auto-titles" plan from /home/jake/.claude/plans/delightful-meandering-moth.md. Out of scope per that plan: settings toggle, regenerate-on-edit, i18n-locale-matching titles. --- src/lib/pages/HistoryPage.svelte | 84 +++++++++++++++++++++++++++++++- src/lib/stores/page.svelte.ts | 33 +++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index 61bbafb..fedadb9 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -26,7 +26,7 @@ import EmptyState from "$lib/components/EmptyState.svelte"; import { formatTime, formatDuration } from "$lib/utils/time.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; - import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte'; + import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Sparkles, Star, Tag } from 'lucide-svelte'; const prefs = getPreferences(); const COLLAPSED_ROW_MIN_HEIGHT = 54; @@ -439,6 +439,66 @@ } } + // Auto-title flow. Mirrors the tag flow above piece-for-piece — same + // per-row + bulk shape, same in-flight Set tracking, same progress + // string. The actual generation lives in `generate_title_cmd` in + // src-tauri/src/commands/llm.rs; the on-demand button here is the + // retry path when auto-title-on-save (in addToHistory) was skipped + // because the LLM wasn't loaded yet. + let titling = $state(new Set()); + let bulkTitling = $state(false); + let bulkTitlingProgress = $state(""); + + async function titleRow(item) { + if (titling.has(item.id)) return; + const next = new Set(titling); + next.add(item.id); + titling = next; + try { + const title = await invoke("generate_title_cmd", { transcript: item.text || "" }); + const trimmed = (title || "").trim(); + if (trimmed) { + await renameHistoryEntry(item.id, { title: trimmed }); + } else { + toasts.warn("No title produced", "The model returned an empty response."); + } + } catch (err) { + toasts.error("Title generation failed", String(err)); + } finally { + const rest = new Set(titling); + rest.delete(item.id); + titling = rest; + } + } + + async function titleAllUntitled() { + if (bulkTitling) return; + bulkTitling = true; + try { + const untitled = history.filter((i) => !i.title || !i.title.trim()); + let done = 0; + let written = 0; + for (const item of untitled) { + done += 1; + bulkTitlingProgress = `${done} / ${untitled.length}`; + try { + const title = await invoke("generate_title_cmd", { transcript: item.text || "" }); + const trimmed = (title || "").trim(); + if (trimmed) { + await renameHistoryEntry(item.id, { title: trimmed }); + written += 1; + } + } catch (err) { + console.error("bulk title failed for", item.id, err); + } + } + toasts.success(`Titled ${written} transcript${written === 1 ? "" : "s"}`); + } finally { + bulkTitling = false; + bulkTitlingProgress = ""; + } + } + async function exportMarkdown(item) { await saveTranscriptAsMarkdown(item); } @@ -565,6 +625,17 @@ {bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"} {/if} + {#if history.some((i) => !i.title || !i.title.trim())} + + {/if} {#if history.length > 0} + {#if item.audioPath && item.segments && item.segments.length > 0}