agent: dogfood polish 2026/04/19 — Linux native chrome + History redesign + mic picker cleanup
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Second dogfood sprint. Headline fix: Linux now uses native KWin/Mutter
decorations instead of fragile frameless `startResizeDragging`, which
collapsed diagonal corner resize to a single axis and made drag feel
laggy. macOS / Windows keep custom chrome via `useCustomChrome` gate.

Other changes:

- Cross-window preferences sync via `kon:preferences-changed` Tauri
  event — theme and font changes propagate live to float/viewer.
- Hotkey recorder rewritten to use capture-phase document listener
  gated by $effect; button focus was unreliable in webkit2gtk.
- History page redesigned for cognitive-load hygiene: title-first
  compact row, inline title input, Edit popout opening /viewer in
  edit mode, clipboard export as .md with YAML frontmatter, manual
  tag chips + + Add tag input, header tag filter (cap 7), global
  Starred filter, `tag:xyz` search syntax.
- `deriveAutoTags` kept as empty hook for post-Task-7 LLM topic tags;
  research found all previous auto-tag chips redundant with row UI.
- Viewer window adds edit mode with debounced-save textarea; native
  title renamed to "Kon - Transcription Editor".
- Window minimums updated per GNOME HIG + WCAG reflow research:
  main 960x600, float 360x480, editor 560x520.
- Microphone picker filters raw ALSA strings (hw:, plughw:, front:,
  sysdefault:, null) and dedupes by CARD=X. New `description` field
  on DeviceInfo reads /proc/asound/cards so Blue Yeti shows as "Blue
  Microphones" instead of the short "Microphones" card name.
- GPU reporting fixed: get_runtime_capabilities now returns
  accelerators=[cpu,vulkan] and whisper.supports_gpu=true, matching
  the transcribe-rs whisper-vulkan feature linked unconditionally.
- ResizeHandles kept for macOS/Windows frameless: 12px edges, 20px
  corners via CSS vars, pointerdown + setPointerCapture, corners
  above edges in z-order, rendered as sibling (not child) of the
  animated layout root so `position: fixed` is viewport-relative.
- Dueling drag-region handlers removed — `data-tauri-drag-region` and
  manual `startDragging()` were stacked on the same elements; kept
  the manual handler which has the button/input early-return logic.

See HANDOVER.md for the full session log and deferred items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 14:30:42 +01:00
parent 8c9c9390d8
commit ea48d03cee
21 changed files with 1079 additions and 157 deletions

View File

@@ -4,6 +4,9 @@
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core";
import {
deriveAutoTags, buildFrontmatter, buildMarkdown, normaliseTag,
} from "$lib/utils/frontmatter.js";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
@@ -12,12 +15,14 @@
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 } from 'lucide-svelte';
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star } from 'lucide-svelte';
const prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54;
const COLLAPSED_ROW_VERTICAL_PADDING = 24;
const EXPANDED_BASE_HEIGHT = 92;
const EXPANDED_TITLE_INPUT_HEIGHT = 48;
const EXPANDED_TAGS_ROW_HEIGHT = 40;
const AUDIO_PLAYER_HEIGHT = 54;
const HISTORY_PREVIEW_LINES = 2;
const HISTORY_DURATION_WIDTH = 48;
@@ -31,6 +36,8 @@
const BUFFER_COUNT = 6;
let searchQuery = $state("");
let showStarredOnly = $state(false);
let activeTagFilter = $state(null); // null = all; string = tag value
let expandedId = $state(null);
let playingId = $state(null);
let audioEl = $state(null);
@@ -47,25 +54,70 @@
stopPlayback();
});
let filtered = $derived(
searchQuery
? history.filter((h) => {
const q = searchQuery.toLowerCase();
return (
(h.text && h.text.toLowerCase().includes(q)) ||
(h.preview && h.preview.toLowerCase().includes(q)) ||
(h.source && h.source.toLowerCase().includes(q)) ||
(h.title && h.title.toLowerCase().includes(q))
);
})
: history
);
function itemHasStar(h) {
if (Array.isArray(h?.segments)) {
return h.segments.some((s) => s?.starred);
}
return Boolean(h?.starred);
}
function itemAllTags(h) {
const auto = deriveAutoTags(h);
const manual = Array.isArray(h?.manualTags) ? h.manualTags : [];
return [...auto, ...manual];
}
function parseTagFilter(q) {
// Matches `tag:value` anywhere in the query; returns { tag, rest }.
const match = q.match(/(?:^|\s)tag:([^\s]+)/i);
if (!match) return { tag: null, rest: q };
const rest = (q.slice(0, match.index) + " " + q.slice(match.index + match[0].length)).trim();
return { tag: match[1].toLowerCase(), rest };
}
let searchParsed = $derived(parseTagFilter(searchQuery || ""));
let allTags = $derived.by(() => {
const counts = new Map();
for (const h of history) {
for (const tag of itemAllTags(h)) {
const t = tag.toLowerCase();
counts.set(t, (counts.get(t) || 0) + 1);
}
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag, count]) => ({ tag, count }));
});
let filtered = $derived.by(() => {
let items = history;
if (showStarredOnly) items = items.filter(itemHasStar);
if (activeTagFilter) {
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === activeTagFilter));
}
if (searchParsed.tag) {
items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === searchParsed.tag));
}
const q = searchParsed.rest.trim().toLowerCase();
if (q) {
items = items.filter((h) => (
(h.text && h.text.toLowerCase().includes(q)) ||
(h.preview && h.preview.toLowerCase().includes(q)) ||
(h.source && h.source.toLowerCase().includes(q)) ||
(h.title && h.title.toLowerCase().includes(q))
));
}
return items;
});
let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13));
let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13));
function compactPreviewText(item) {
return item.title || item.preview || item.text || "";
// The compact row shows the title (or a placeholder). The transcript
// body lives in the expanded drawer so the two are visually distinct.
return item.title?.trim() || "Untitled";
}
let compactPreviews = $derived.by(() => {
@@ -123,10 +175,11 @@
);
let height = compactHeight;
if (expandedId === item.id) {
height += EXPANDED_BASE_HEIGHT + EXPANDED_TITLE_INPUT_HEIGHT + EXPANDED_TAGS_ROW_HEIGHT;
const transcriptHeight = item.text && textWidth > 0
? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height
: historyLineHeight;
height += transcriptHeight + EXPANDED_BASE_HEIGHT;
height += transcriptHeight;
if (item.audioPath && playingId === item.id) {
height += AUDIO_PLAYER_HEIGHT;
}
@@ -192,27 +245,6 @@
}
}
async function renameItem(item) {
const name = prompt("Name this transcript:", item.title || "");
if (name === null) return;
const trimmed = name.trim();
item.title = trimmed;
item.preview = trimmed ? `${trimmed}${item.text.slice(0, 80)}` : item.text.slice(0, 120);
// Persist via the dual-write helper. Updates SQLite + localStorage and
// surfaces a toast on failure (Day 4 of the upgrade plan, closes
// architecture-review.md §13).
try {
await renameHistoryEntry(item.id, { title: trimmed });
} catch (err) {
toasts.warn(
"Rename did not persist",
"Your change is visible now but did not save. It may revert on next launch."
);
}
}
function togglePlay(item) {
if (playingId === item.id) {
if (audioEl && !audioEl.paused) {
@@ -276,9 +308,57 @@
async function openViewer(item) {
try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_mode", "view");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_mode", "view");
window.open("/viewer", "_blank", "width=600,height=700");
}
}
function handleAddTagKey(e, item) {
if (e.key !== "Enter" && e.key !== ",") return;
e.preventDefault();
const raw = e.target.value || "";
const next = normaliseTag(raw);
if (!next) return;
const existing = new Set((item.manualTags || []).map((t) => normaliseTag(t)));
if (existing.has(next)) {
e.target.value = "";
return;
}
item.manualTags = [...(item.manualTags || []), next];
saveHistory();
e.target.value = "";
}
function removeManualTag(item, tag) {
const t = normaliseTag(tag);
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
saveHistory();
}
async function exportMarkdown(item) {
const md = buildMarkdown(item);
try {
await navigator.clipboard.writeText(md);
} catch {
try {
await invoke("copy_to_clipboard", { text: md });
} catch {}
}
toasts.info("Markdown copied to clipboard — paste into Obsidian or save as .md");
}
async function openEditor(item) {
try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_mode", "edit");
await invoke("open_viewer_window");
} catch {
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_mode", "edit");
window.open("/viewer", "_blank", "width=600,height=700");
}
}
@@ -308,6 +388,16 @@
<h2 class="font-display text-[26px] italic text-text">History</h2>
<span class="text-[11px] text-text-tertiary mt-1">{history.length} saved</span>
<div class="flex-1"></div>
<button
class="inline-flex items-center gap-1.5 btn-md rounded-lg
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
onclick={() => (showStarredOnly = !showStarredOnly)}
aria-pressed={showStarredOnly}
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
>
<Star size={14} aria-hidden="true" />
<span class="text-[11px]">Starred</span>
</button>
{#if history.length > 0}
<button
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
@@ -319,13 +409,13 @@
</div>
<!-- Search -->
<div class="px-7 pb-4">
<div class="px-7 pb-3">
<Card>
<div class="flex items-center gap-3 px-4 py-2.5">
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
<input
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
placeholder="Search all transcripts..."
placeholder="Search all transcripts... (try tag:meetings)"
bind:value={searchQuery}
data-no-transition
/>
@@ -340,6 +430,33 @@
</Card>
</div>
<!-- Tag chip filter bar -->
{#if allTags.length > 0}
<div class="px-7 pb-3 flex items-center gap-1.5 flex-wrap">
<span class="text-[10px] uppercase tracking-wider text-text-tertiary mr-1">Tags</span>
{#if activeTagFilter}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-accent text-[10px] text-accent bg-accent/10"
onclick={() => (activeTagFilter = null)}
title="Clear tag filter"
>
{activeTagFilter}
<span class="text-[12px] leading-none">×</span>
</button>
{:else}
{#each allTags.slice(0, 7) as t (t.tag)}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-subtle text-[10px] text-text-secondary hover:border-accent hover:text-accent"
onclick={() => (activeTagFilter = t.tag)}
>
{t.tag}
<span class="text-[9px] text-text-tertiary">{t.count}</span>
</button>
{/each}
{/if}
</div>
{/if}
<!-- History list -->
<div class="flex-1 px-7 pb-4 min-h-0">
<Card classes="h-full flex flex-col overflow-hidden">
@@ -456,6 +573,48 @@
</div>
{/if}
<!-- Inline title input -->
<input
type="text"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent mb-3"
placeholder="Name this transcript..."
value={item.title || ""}
oninput={(e) => { item.title = e.target.value; }}
onblur={() => renameHistoryEntry(item.id, { title: (item.title || "").trim() }).catch(() => {})}
onclick={(e) => e.stopPropagation()}
data-no-transition
/>
<!-- Tags: auto + manual -->
<div class="flex items-center gap-1.5 flex-wrap mb-3" onclick={(e) => e.stopPropagation()} role="presentation">
{#each deriveAutoTags(item) as t (t)}
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] bg-bg-input text-text-tertiary border border-border-subtle"
title="Auto-generated tag"
>{t}</span>
{/each}
{#each (item.manualTags || []) as t (t)}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-accent/10 text-accent border border-accent/30">
{t}
<button
class="text-[12px] leading-none hover:text-danger"
onclick={() => removeManualTag(item, t)}
title="Remove tag"
aria-label="Remove tag {t}"
>×</button>
</span>
{/each}
<input
type="text"
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[110px]"
placeholder="+ add tag"
onkeydown={(e) => handleAddTagKey(e, item)}
data-no-transition
/>
</div>
<!-- Full transcript -->
<p class="text-[13px] text-text whitespace-pre-wrap mb-4" style="line-height: {historyLineHeight}px">{item.text}</p>
@@ -464,13 +623,23 @@
<button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); renameItem(item); }}
>Rename</button>
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Copy</button>
<button
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); openEditor(item); }}
title="Open transcript in a popout editor"
>
Edit
<ExternalLink size={11} aria-hidden="true" />
</button>
<button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Copy</button>
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
title="Export this transcript as a Markdown file with YAML frontmatter"
>Export .md</button>
{#if item.audioPath && item.segments && item.segments.length > 0}
<button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"

View File

@@ -34,6 +34,64 @@
let audioDevices = $state([]);
let audioDevicesError = $state(null);
// ALSA enumeration leaks raw device strings (hw:, plughw:, front:,
// sysdefault:, back:, surround:, iec958:, dmix:, usbstream:, plus a
// bogus "null" device). These are kernel-level aliases — no user
// needs to pick between "hw:CARD=2,DEV=0" and "plughw:CARD=2,DEV=0".
//
// The strategy: keep a small set of well-known sentinel devices
// (default/pipewire/pulse), then pull a single entry per unique
// sound card by parsing CARD=X from the sysdefault: alias. That
// gives us "Microphones" / "C920" / "Generic" as friendly labels
// while mapping them to a reliable ALSA path.
const SENTINEL_DEVICES = new Set(["default", "pipewire", "pulse"]);
function parseCardName(name) {
const match = String(name || "").match(/CARD=([^,]+)/);
return match ? match[1] : null;
}
function buildVisibleDevices(devices) {
const out = [];
const seenCards = new Set();
for (const dev of devices) {
const name = dev?.name || "";
if (!name || name === "null") continue;
if (SENTINEL_DEVICES.has(name)) {
out.push(dev);
continue;
}
if (name.startsWith("sysdefault:CARD=")) {
const card = parseCardName(name);
if (card && !seenCards.has(card)) {
seenCards.add(card);
out.push(dev);
}
}
// Everything else (hw:, plughw:, front:, dmix:, etc.) is silently
// dropped — cpal will resolve the sysdefault:CARD= form fine.
}
return out;
}
function friendlyLabel(dev) {
const name = dev?.name || "";
if (name === "default") return "System default";
if (name === "pipewire") return "PipeWire";
if (name === "pulse") return "PulseAudio";
// Prefer the rich product description from /proc/asound/cards
// (e.g. "Blue Microphones" for the Yeti). Falls back to the raw
// CARD=X short name if we couldn't load descriptions.
const desc = (dev?.description || "").trim();
if (desc) return desc;
const card = parseCardName(name);
if (card) return card;
return name;
}
let visibleAudioDevices = $derived(buildVisibleDevices(audioDevices));
async function refreshAudioDevices() {
audioDevicesError = null;
try {
@@ -448,11 +506,9 @@
onfocus={refreshAudioDevices}
>
<option value="">Auto (recommended) — let Kon pick the working mic</option>
{#each audioDevices as dev}
{#each visibleAudioDevices as dev}
<option value={dev.name} disabled={dev.is_likely_monitor}>
{dev.name}
{dev.is_default ? " (system default)" : ""}
{dev.is_likely_monitor ? " — speaker monitor, skip" : ""}
{friendlyLabel(dev)}{#if dev.is_default && dev.name !== "default"} (system default){/if}{#if dev.is_likely_monitor} — speaker monitor, skip{/if}
</option>
{/each}
</select>
@@ -464,7 +520,7 @@
</div>
{#if audioDevicesError}
<p class="text-[11px] text-error mt-2">{audioDevicesError}</p>
{:else if audioDevices.length === 0}
{:else if visibleAudioDevices.length === 0}
<p class="text-[11px] text-text-tertiary mt-2">No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.</p>
{:else}
<p class="text-[11px] text-text-tertiary mt-2">