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

@@ -0,0 +1,142 @@
// Transcript frontmatter + auto-tag derivation.
//
// A transcript's "frontmatter" is a flat object of metadata that can be
// exported as YAML for Obsidian or other Markdown consumers. Auto-tags are
// derived deterministically from existing fields (date, duration, source,
// text length) so they stay in sync without migration.
//
// Storage model:
// - Source of truth is the existing transcript fields (id, title, date,
// duration, source, text, segments).
// - Manual tags live on `item.manualTags: string[]`.
// - Auto-tags are never stored — derived on demand.
const DURATION_BUCKETS = [
{ max: 60, tag: "duration:short" }, // < 1 minute
{ max: 300, tag: "duration:medium" }, // < 5 minutes
{ max: 1800, tag: "duration:long" }, // < 30 minutes
{ max: Infinity, tag: "duration:very-long" },
];
const WORD_BUCKETS = [
{ max: 50, tag: "words:short" },
{ max: 300, tag: "words:medium" },
{ max: 1500, tag: "words:long" },
{ max: Infinity, tag: "words:very-long" },
];
function durationTag(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
}
function wordCountTag(text) {
if (!text || typeof text !== "string") return null;
const count = text.trim().split(/\s+/).filter(Boolean).length;
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
}
// Resolve time-of-day bucket from an ISO date or a legacy string like
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
function timeOfDayTag(dateStr) {
if (!dateStr) return null;
let ts = Date.parse(dateStr);
if (Number.isNaN(ts)) {
// Try DD/MM/YYYY, HH:MM:SS (UK local format used by Kon history rows).
const match = String(dateStr).match(
/(\d{1,2})\/(\d{1,2})\/(\d{4})[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?/,
);
if (!match) return null;
const [, dd, mm, yyyy, hh, min, ss] = match;
const d = new Date(
Number(yyyy), Number(mm) - 1, Number(dd),
Number(hh), Number(min), Number(ss || 0),
);
ts = d.getTime();
}
const hour = new Date(ts).getHours();
if (hour >= 6 && hour < 12) return "time:morning";
if (hour >= 12 && hour < 18) return "time:afternoon";
if (hour >= 18 && hour < 22) return "time:evening";
return "time:night";
}
function sourceTag(source) {
if (!source) return null;
const s = String(source).toLowerCase();
if (s.includes("file")) return "source:file";
if (s.includes("live") || s.includes("mic")) return "source:live";
return `source:${s.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")}`;
}
// Returns tags to display as chips. Intentionally empty by default: the
// metadata these tags used to encode (duration, date, source, starred) is
// already shown elsewhere in the History row, so chips would duplicate
// information and add cognitive load without improving retrieval. The
// function is kept as a hook for one future AI-derived content tag
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
export function deriveAutoTags(_item) {
return [];
}
export function normaliseTag(raw) {
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
}
// Build the flat frontmatter object that represents a transcript's metadata.
// Shown in the expanded History row and serialised when exporting to .md.
export function buildFrontmatter(item) {
if (!item) return {};
const auto = deriveAutoTags(item);
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
const tags = Array.from(new Set([...auto, ...manual.map(normaliseTag)])).filter(Boolean);
const wordCount = item.text ? item.text.trim().split(/\s+/).filter(Boolean).length : 0;
return {
id: item.id,
title: item.title || null,
date: item.createdAt || item.date || null,
duration_s: Number.isFinite(item.duration) ? item.duration : null,
source: item.source || null,
word_count: wordCount,
tags,
};
}
// Escape a YAML scalar. Keeps things simple — quote if it contains any
// character that would otherwise need escaping in plain scalars.
function yamlScalar(value) {
if (value === null || value === undefined) return "null";
if (typeof value === "number" || typeof value === "boolean") return String(value);
const s = String(value);
if (s === "") return '""';
if (/^[A-Za-z0-9._/:\- ]+$/.test(s) && !/^\s|\s$/.test(s)) return s;
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
export function serialiseFrontmatter(fm) {
const lines = ["---"];
for (const [key, value] of Object.entries(fm)) {
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}:`);
for (const v of value) lines.push(` - ${yamlScalar(v)}`);
}
} else {
lines.push(`${key}: ${yamlScalar(value)}`);
}
}
lines.push("---");
return lines.join("\n");
}
// Produce an Obsidian-flavoured markdown document for a transcript.
export function buildMarkdown(item) {
const fm = buildFrontmatter(item);
const header = serialiseFrontmatter(fm);
const title = fm.title || "Transcript";
const body = item?.text || "";
return `${header}\n\n# ${title}\n\n${body}\n`;
}