Closes Phase 3 of the 2026-04-23 feature-complete roadmap. Incorporates
the Codex plan-review fixes from this session: profile-free index, tri-
state update command, and de-prioritise-not-hide semantics.
Storage (kon-storage):
- Migration v11 adds `energy TEXT` to `tasks` with a CHECK constraint on
`high | medium | brain_dead | NULL`. Index `(energy, created_at DESC)`
— deliberately not per-profile because the tasks table carries no
profile_id column yet (tracked as a separate gap in HANDOVER).
- `TaskRow.energy: Option<String>` plus `task_row_from` read.
- `insert_task` signature grows by one optional arg (`energy`). Allowed
`too_many_arguments` with a rationale comment — the positional shape
matches the column order and flipping to a params struct would have
rippled through every caller for cosmetic benefit only.
- New `set_task_energy(pool, id, Option<&str>) -> TaskRow`. Lives as its
own function because `update_task` uses COALESCE to let `None` mean
"preserve" — which would make clearing the tag impossible.
- Two new tests: round-trip including explicit NULL clear, and CHECK
constraint rejection of unknown values.
- Tests updated for the v10 → v11 version bump.
Tauri (src-tauri):
- `TaskDto.energy`. `CreateTaskRequest.energy` (optional). Inline
validation against the allowed set before hitting the DB, so frontend
bugs surface as friendly errors instead of CHECK-constraint failures.
- New `set_task_energy_cmd` command mirroring the storage tri-state API.
Frontend (svelte):
- `EnergyLevel` type added to `types/app.ts`. `TaskDto`, `TaskEntry`, and
`TaskDraft` grow an `energy` field.
- `SettingsState.currentEnergy` (persisted) + `matchMyEnergy` (persisted
toggle). Defaults: null + false — no surface change until user opts in.
- `setTaskEnergy(id, EnergyLevel | null)` action on the task store.
Calls the dedicated Tauri command, updates local state, broadcasts to
sibling windows.
- `EnergyChip.svelte` — new component. Cycles unset → High → Medium →
Brain-Dead → unset on click. Colour tokens: accent / warning /
text-tertiary (deliberately not danger-red for Brain-Dead — the brief
is explicit that this state must not feel pathologised).
- Chip rendered on every task row in TasksPage and every row in
WipTaskList. Hidden-until-hover when energy is unset so untagged rows
stay calm; always visible once tagged because the colour is the signal.
- Tasks page header gains a "I feel" segmented control and a
"Match my energy" toggle. When both are active, matching tasks sort
to the top — unset tasks are treated as Medium-equivalent. Nothing is
ever hidden; this is a de-prioritisation, not a filter.
Deferred / out of scope:
- LLM-driven surfacing (brief says "The AI surfaces...") — deterministic
client-side sort is v1; LLM layer is a later phase.
- tasks.profile_id + per-profile energy sort — separate migration.
All green: cargo build + 251 tests + clippy -D warnings (0 warnings)
+ fmt + svelte-check (0/0) + npm run build.
Independent review surfaced three majors and one nit. All actioned.
MAJOR 1 — profile scoping:
`decompose_and_store` and `extract_tasks_from_transcript_cmd` now
accept an optional `profile_id` (wired from `profilesStore.activeProfileId`
in MicroSteps.svelte and DictationPage.svelte), and thread it into the
feedback-retrieval query so per-profile decomposition styles do not
leak into each other. `record_feedback` gets the same treatment.
MAJOR 2 — prompt-budget regression on long inputs:
New `trim_to_budget` helper + `FEW_SHOT_CHAR_BUDGET = 2000` char cap
in `src-tauri/src/commands/tasks.rs`. Retrieval still pulls up to 5
rows but they are char-counted and truncated against the budget
before being sent to the LLM. Char cost matches the `Input: ...\n
Good output: ...` render path so the budget maps cleanly to ~570
Qwen3 tokens, well inside the 8192-context reserve after the 512-
or 768-token response allocation. Oldest-first drop order (iteration
stops at cost exceeded) preserves the most recent correction which
is the one carrying the user's live preference.
MAJOR 3 — inline edit stale-rollback race:
`saveEdit` in MicroSteps.svelte now stamps a monotonic per-step
`saveToken`. Each edit bumps the token; on failure the rollback
only fires if `saveToken[step.id] === myToken`, so a slow-failing
first save can no longer overwrite a faster successful second save.
NIT — retrieval ordering stability:
`list_feedback_examples` ORDER BY now `created_at DESC, id DESC`.
SQLite timestamp precision is one second; without the secondary
key, bursty feedback within the same second would select
non-deterministically.
Also: malformed `context_json` now warns via eprintln! rather than
disappearing silently — Codex minor.
All green: cargo build + 249 tests + clippy -D warnings + fmt
+ svelte-check (0/0) + npm run build.
Closes the human-in-the-loop gap from docs/brief/feature-set.md and
Phase 2 of the 2026-04-23 feature-complete roadmap.
Storage (kon-storage):
- Migration v10 adds the `feedback` table: (target_type, target_id,
rating, original_text, corrected_text, context_json, profile_id,
created_at) with CHECK constraints on target_type and rating, plus
indexes on (target_type, rating, created_at DESC) for prompt-time
retrieval and (profile_id, target_type, created_at DESC) for
per-profile scoping.
- New public API: `FeedbackTargetType`, `RecordFeedbackParams`,
`FeedbackRow`, `record_feedback`, `list_feedback_examples`.
- Tests updated — the RB-02 rollback regression now discovers the
real max version at runtime instead of hard-coding v10 for its
poison migration.
LLM (kon-llm):
- `prompts::FeedbackExample` — local shape for few-shot exemplars so
kon-llm stays independent of kon-storage.
- `prompts::build_conditioned_system_prompt` — appends a "here is
the style this user prefers" block to the base system prompt
when examples are available; returns the base prompt unchanged
when empty, so new users and early sessions see generic output.
- `LlmEngine::decompose_task_with_feedback` and
`LlmEngine::extract_tasks_with_feedback` thread examples through
to the builder. The old one-arg variants are preserved and now
call through with an empty slice.
- 4 unit tests covering empty, empty-input-skip, correction-wins,
and thumbs-up-only fallback.
Tauri (src-tauri):
- New commands::feedback module: `record_feedback`,
`list_feedback_examples_cmd`.
- `decompose_and_store` and `extract_tasks_from_transcript_cmd`
now fetch the last 5 positive/neutral feedback rows for their
target type and pass them through to the LLM, wiring the
learning loop end-to-end.
- Shared `to_llm_examples` helper parses the `context_json.input`
field (where the recorder stashes the parent task text / transcript
chunk) back into the exemplar shape.
Frontend (MicroSteps.svelte):
- Thumbs-up and thumbs-down buttons on every micro-step row.
Hover-revealed; the vote recolours the icon; clicking again
clears the local highlight (the row itself stays in the audit
trail).
- Pencil icon + double-click to edit step text. Save flows through
update_task_cmd for persistence and records a correction feedback
row with (original_text, corrected_text) — the highest-value
training signal.
- Parent task text is captured in context_json.input at record time
so the prompt builder can reconstruct the (input, preferred-output)
pair on subsequent decompositions.
- Feedback capture is best-effort — a record_feedback failure never
interrupts the primary action.
What's deferred to a later phase:
- Thumbs + corrections on extracted tasks (same pipeline, different
surface — probably TasksPage after the AI-extraction path)
- Thumbs on transcript cleanup output
- Semantic retrieval over the feedback corpus (once there is enough
data to justify embedding infrastructure; the storage shape is
already ready for it)
Jake's feedback on Phase 1: make the timer pinnable / always-on-top,
combined with the existing Now-list pop-out. Two changes:
1. Mount <FocusTimer /> in src/routes/float/+layout@.svelte so the
running countdown stays visible in the always-on-top float window
alongside the WIP task list. No content change to the float page
itself — the timer is a global overlay.
2. Add a pop-out icon to the main-window focus timer that opens the
existing /float route via window.open. One click → timer + Now
list pinned on top without touching main window focus. Hidden
inside the float window itself (detected via URL) so you cannot
recursively pop out.
Result matches the Todo float-out UX the user already knows:
click ExternalLink, you get a small always-on-top window with
tasks + a live countdown ring in the top-right.
Closes the Core MVP gap in docs/brief/feature-set.md ("visual time
representation") and wires the dangling kon:start-timer emit that
MicroSteps.svelte has been firing into the void since the stub was
written. Implements phase 1 of the 2026-04-23 feature-complete
roadmap.
New:
- src/lib/stores/focusTimer.svelte.ts — singleton timer store with
localStorage persistence so a timer started in Dictation survives
page nav, window close, and reopen. Gentle WebAudio chime at
completion (no bundled asset). 250 ms tick. Completion flash for
3 s before auto-clear.
- src/lib/components/FocusTimer.svelte — floating top-right overlay
with SVG progress ring (shrinking colour: accent -> warning in
the final 15% -> success on completion). Cancel + "+1 min" on
hover. Renders nothing when idle.
Wired in +layout.svelte next to ToastViewport.
Two triggers now in the app:
- MicroSteps row 2-min button (pre-existing emit, previously no
listener)
- WipTaskList row 5-min button (new; the brief's "just-start"
from the Now column)
Respects prefers-reduced-motion via the existing
[data-reduce-motion] attribute.
Out of scope, carried to later phases: rhythmic voice anchoring
(Phase 6), custom-duration picker, multi-timer UI, native OS
notification (deferred to Phase 6 with the full nudge pipeline).
The LLM runtime has been quiet since it shipped in Phase 3 — users
had no surface-level signal that cleanup was loaded, warming, or
actively generating. Settings has verbose status text internally,
but a dictation-flow user never opens Settings during a run.
New: a shared $state store drives a small chip in the sidebar that
reflects the true LLM state in ≤500 ms of any transition (brief
item #31 acceptance). Five states:
off → hidden (user has aiTier === "off")
warming → model download or first load in flight; amber pulse
ready → loaded + idle; green dot
generating → cleanup_transcript_text_cmd or
extract_tasks_from_transcript_cmd in flight; accent
pulse with Sparkles icon
error → last operation failed; red dot with AlertTriangle
The store exposes three calls: refreshLlmStatus(aiTier) (polls the
backend), markGenerating(detail) / markGenerationDone(success).
DictationPage wraps its cleanup + extract calls in mark-generating
pairs. SettingsPage's LLM load / unload / delete / download paths
also refresh the global store so Settings-initiated transitions
surface in the sidebar immediately. The chip collapses to a
dot-only compact form when the sidebar is collapsed.
No backend changes — everything wires onto the existing
`get_llm_status` Tauri command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/lib/utils/hotkeyValidity.ts with validateHotkey(combo, os)
and wires it into HotkeyRecorder.svelte. Rules:
- X11/Wayland/Linux: reject single-key combos unless the trigger
is F13..F24 (the conventional global-shortcut escape hatch).
- Windows: reject combos whose only modifier is a right-hand
variant (RCtrl/RAlt) — matches Handy #966's failure mode where
RegisterHotKey silently ignores them.
- macOS: reject Fn-only combos and bare-key combos (non-function
keys).
- unknown OS: pass through — better to ship a flawed combo than
reject one we can't validate.
When validation fails, the recorder leaves the previous known-good
hotkey intact and surfaces an inline warning-coloured sentence
explaining why, with actionable copy ("add a modifier", "use the
left-hand equivalent", etc.). The save button disappears because
settings.globalHotkey is never written to — no state drift.
Matches Handy #917 / #1019 / #966 / #956, brief item #5.
Co-authored-by: jars <jakejars@users.noreply.github.com>
webkit2gtk under KDE Wayland does not set e.metaKey on subsequent events
after the compositor intercepts Super — but the Super keydown itself is
still delivered with e.key === "Super". Track pressed modifiers from
raw keydown/keyup and OR with the webkit booleans so combos like Super+E
(which OpenWhispr already registers successfully via evdev) can now be
captured in Kon's recorder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness fixes to the hotkey recorder based on dogfood logs:
1. Modifier set now includes Super / OS / Hyper (webkit2gtk on Linux
fires e.key === "Super" for the Windows key — previously that key
got captured as the final trigger, producing invalid 'Ctrl+Shift+Super'
strings the evdev parser rejected).
2. resolveTriggerKey() uses e.code (physical, shift-independent key)
to resolve shifted punctuation back to the unshifted name the evdev
parser understands: '+' -> '=', '|' -> '\\', etc. Letters and digits
also use e.code (KeyA -> A, Digit1 -> 1) to avoid layout quirks.
3. Numpad keys intentionally not mapped to main-keyboard equivalents —
they are distinct evdev codes. Leaves the parser to reject them so
the user gets a toast instead of a silently-wrong binding.
Registration failures now surface as a toast and revert
settings.globalHotkey to the last successfully-registered value (if
any), so the UI cannot lie about what is actually bound.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wholesale JS -> TS migration of the frontend — stores, utils, actions,
and all Svelte component scripts adopt type annotations. Compile-time
surfaces (app.d.ts, lib/types/) added for shared DTO types.
Build plumbing:
- package.json: dev:frontend script that runs svelte-kit sync first
- tauri.conf.json: beforeDevCommand points at dev:frontend
- run.sh: dropped the sed-hack that temporarily blanked beforeDevCommand;
now relies on npm run dev:frontend to avoid double-Vite
- jsconfig.json: allowImportingTsExtensions
Preserves all Group 1 behaviour:
- page.svelte.ts keeps loadHistory / loadTasks Tauri-first, no
localStorage; saveTranscriptMeta + mapTranscriptRow + mapTaskRow
intact; update_task_cmd and update_transcript_meta_cmd invocations
carry the correct payload shape.
- Toasts, preferences stores typed without behaviour change.
- Viewer still routes segment edits through saveTranscriptMeta; the
Task 1.5 TODO markers are gone.
taskExtractor.ts is functionally improved during the migration:
- multi-task matches in the same sentence
- list-style shopping-verb expansion (get bread, milk, and cheese)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tailwindcss/vite:generate:serve threw 'Invalid declaration:
getCurrentWindow' on ResizeHandles.svelte?svelte&type=style&lang.css
even after comment sanitisation. The style block is plain CSS with no
Tailwind directives, but the per-component virtual CSS module route
was hitting a parse bug in the Tailwind v4 + Svelte 5 combination.
Workaround: move the CSS into app.css (class names are already
component-specific, no scoping loss) and drop the component-local
<style> block. This sidesteps the virtual-module route entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 92ac7ea. Two apostrophes remained (KWin's and don't)
that kept tripping Tailwind v4 JIT the same way. Removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to d1d344b. Tailwind's scanner still saw backtick-quoted
fragments like `decorations: false` and `position: fixed` in the
script comments as CSS property declarations, tripping on the next
JS identifier (getCurrentWindow). Removing all backticks from the
comment block sidesteps the entire scanner path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tailwind v4 JIT scanner mis-tokenised the comment 'Tauri`s `data-tauri-drag-region`'
as an unterminated string literal, breaking dev-server HMR. Comment rewritten
to avoid apostrophes near backticks. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
textMeasure.js, virtualList.js, and accessibilityTypography.js were
added in the 2026/04/04 session alongside VirtualSegmentList.svelte but
never committed. All four are imported from DictationPage, HistoryPage,
SettingsPage, and the viewer — a fresh clone had unresolvable imports.
textMeasure.js: pretext-backed text measurement with LRU cache.
measureTextHeight, measurePreWrap, clampTextLines, invalidateCache.
virtualList.js: binary-search visible range for variable-height virtual
lists. buildCumulativeOffsets + findVisibleRange.
accessibilityTypography.js: pretext font/line-height helpers that read
the user's accessibility preference store (Lexend / Atkinson /
OpenDyslexic mapping).
VirtualSegmentList.svelte: virtualised transcript segment renderer used
by the viewer page; depends on all three utils above.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
THE PROBLEM (Codex review + architecture-review.md §1, §14):
Multiple critical paths discard errors silently — `let _ = insert_transcript(...)`,
`.catch(() => {})`, inline `error = ...` state that not every page surfaces. ND
users in particular need explicit feedback when something fails; silent
failure is worse than no feature.
SHIPPED:
src/lib/stores/toasts.svelte.js (new):
- Minimal in-house toast store (~80 lines, no svelte-french-toast dep)
- Severity → palette: info/success → moss, warn → signal, error → ember
- Auto-dismiss durations per severity (success 3s, info 4s, warn 6s,
error sticky)
- `invokeWithToast(invoke, command, args, errorTitle?)` helper wraps a
Tauri invoke and toasts on failure while still throwing for callers
that need to handle the error themselves
src/lib/components/ToastViewport.svelte (new):
- Reads from the toasts store
- Bottom-right stack, max-width clamp on small screens
- aria-live=polite + role=alert on error toasts (screen-reader friendly)
- Slide-in animation honours `html.reduce-motion` for the existing
preferences toggle
- Severity left-border in brand colours via existing CSS variables
src/routes/+layout.svelte:
- Mounts <ToastViewport /> once at the app root so toasts work from any
page or window
src/lib/pages/DictationPage.svelte:
- First error-toast wiring: failures starting recording now show a
sticky toast in addition to the inline `error` panel
- Replaces a previously easy-to-miss failure-mode
NEXT (Day 4 batch):
- Wire toasts into FilesPage, HistoryPage, SettingsPage, ModelDownloader
(all currently swallow errors)
- Add `add_transcript`, `list_transcripts`, `update_transcript` (closes the
long-standing TODO Codex flagged), `delete_transcript` Tauri commands
wrapping the existing storage layer
- Switch addToHistory to dual-write (localStorage + SQLite) so the
canonical store catches up with the actual transcript flow
- FTS5 transcripts_fts virtual table + search command
- Wrap multi-row writes (decompose_and_store) in DB transactions
prevTaskIds was declared as $state, causing the $effect that writes to
it to re-trigger itself infinitely (effect_update_depth_exceeded). The
variable is just a comparison reference — doesn't need to be reactive.
Changed to a plain let.
Also removed debug event loggers and reverted grain z-index.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add sidebarCollapsed to settings defaults (persists via localStorage on saveSettings)
- Sidebar collapses to 48px icons-only view with smooth 200ms transition
- Toggle button (chevron) at top of nav; [ keyboard shortcut in layout (input-safe)
- Auto-collapse on window resize below 900px (checked on mount and on resize event)
- Titlebar left spacer tracks sidebar width reactively (48px / 210px)
- All labels, profile selector, status text hidden in collapsed mode; nav items show tooltips via title attribute
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>