17 Commits

Author SHA1 Message Date
46be0a5aca feat(feedback): Phase 2 — HITL thumbs + correction capture with prompt-conditioning loop
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
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)
2026-04-24 12:53:51 +01:00
f25f8db818 feat(focus-timer): integrate with float window + add pop-out button
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.
2026-04-24 12:06:37 +01:00
bbc7c217be docs(roadmap): archive Phase 1 focus-timer screenshot (sent to Jake)
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
2026-04-24 12:00:51 +01:00
0c34a29367 feat(focus-timer): Phase 1 — visual countdown ring + just-start timer
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).
2026-04-24 11:50:45 +01:00
df6b19834d docs(roadmap): feature-complete plan for v0.1, 10 phases from visual-countdown through Corbie rename + release 2026-04-24 11:43:48 +01:00
420da679f9 docs(handover): post-consolidation follow-up — npm audit triaged, clippy now zero, design-system WIP abandoned
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
2026-04-24 11:33:12 +01:00
2b82b9be5b refactor(live): rewrite needless_range_loop in duplicate-window merge with slice iterator 2026-04-24 10:59:31 +01:00
0e18a78fae chore(deps): bump @sveltejs/kit 2.57.1 -> 2.58.0 and adapter-static 3.0.6 -> 3.0.10 2026-04-24 10:58:17 +01:00
4700668df1 docs(readme): refresh test count 136 -> 245
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
2026-04-24 09:53:26 +01:00
4e947dec21 docs: 2026-04-23 handover + test count refresh (136 -> 245) 2026-04-24 09:52:23 +01:00
509b983c09 chore(deps-dev): bump vite (dependabot, npm_and_yarn group) 2026-04-24 09:44:13 +01:00
0b1c492edd chore(deps-dev): bump @sveltejs/kit (dependabot) 2026-04-24 09:44:13 +01:00
6579c5fb6a chore(deps-dev): bump picomatch (dependabot) 2026-04-24 09:44:13 +01:00
fe61661305 chore(lint): clean up clippy warnings across workspace
Auto-applied cargo clippy --fix across 11 files — needless return,
unnecessary cast, map_or simplification, repeat().take() → repeat_n(),
iter().any() → contains(), manual char comparison, lifetime elision,
push_str single-char, reference immediately dereferenced.

Also fixed three lints on file_storage.rs manually: two doc-list-item
overindentations, plus the same needless-return. Baseline main was
not clippy-clean with -D warnings before; after this pass one
needless_range_loop warning remains (live.rs:1089) that clippy's
suggested rewrite would make less readable — left for a dedicated
refactor session.

Build + workspace tests remain green (245 passing, 0 failing, 1
ignored).
2026-04-24 09:43:56 +01:00
dependabot[bot]
f8c9769e04 chore(deps-dev): bump picomatch
Bumps the npm_and_yarn group with 1 update in the / directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 15:10:24 +00:00
dependabot[bot]
becbf69c35 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 15:10:23 +00:00
dependabot[bot]
b8b953dfa8 chore(deps-dev): bump @sveltejs/kit
Bumps the npm_and_yarn group with 1 update in the / directory: [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit).


Updates `@sveltejs/kit` from 2.55.0 to 2.57.1
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.57.1/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.57.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 15:10:15 +00:00
32 changed files with 1782 additions and 149 deletions

97
HANDOVER-2026-04-19.md Normal file
View File

@@ -0,0 +1,97 @@
---
name: handover-2026-04-19
type: reference
tags: [handover, session, kon]
description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome
---
# Kon Handover — 2026/04/19
Second dogfood sprint. Four phases: (1) fix bugs surfaced on first real use, (2) redesign History for cognitive-load hygiene, (3) resolve broken window resize/drag on Linux Wayland, (4) clean up microphone picker.
## What shipped this session
### Cross-window preferences sync
- `preferences.svelte.js` emits `kon:preferences-changed` Tauri event on update.
- Main / viewer / float layouts listen and call `applyExternalPreferences` without re-emit, so theme and font changes propagate live across sibling windows.
- Echo suppressed via source window label check.
### Hotkey recorder
- Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee.
- Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels.
### History page redesign (research-backed)
- Compact row now shows the **title** (or "Untitled"), not body-preview text — metadata already lives in the row columns (date, duration, source icon).
- Expanded row gets an inline title input (replaces the old Rename prompt modal).
- **Edit** button opens the viewer window in `edit` mode (editable textarea, debounced save to localStorage + storage-event sync back to main history).
- **Export .md** copies a full YAML-frontmatter markdown document to the clipboard — paste into Obsidian.
- **Tags**: `$lib/utils/frontmatter.js` exposes `deriveAutoTags` (currently returns `[]`), `buildFrontmatter`, `serialiseFrontmatter`, `buildMarkdown`. Manual tags stored as `item.manualTags`, rendered as removable chips in the expanded row with `+ add tag` input.
- Header tag chip bar (cap 7, click to filter, × to clear), plus `tag:xyz` search syntax.
- Global **Starred** filter toggle in the History header.
- Research memo found all five previous auto-tag families redundant with existing row UI — kept the derivation hook for the post-Task-7 `topic:*` content tag from kon-llm.
- Duplicate-transcript render fix: expanded `<p>` only if compact preview actually truncated.
### Viewer / editor popout
- `/viewer` route now reads `kon_viewer_mode` from localStorage ("view" | "edit").
- Edit mode renders a plain textarea bound to `item.text`; 400ms debounced save flushes on input, final flush on `onDestroy`. Segment-specific controls (Compact, Starred) hidden in edit mode.
- Native title: **"Kon - Transcription Editor"**.
### Platform-aware window chrome (Linux fix)
**Root cause:** Tauri v2 frameless `decorations: false` on KDE Wayland + webkit2gtk does not honour diagonal corner resize (collapses `NorthEast` etc. to a single axis via GTK's `gtk_window_begin_resize_drag`), and `data-tauri-drag-region` adds noticeable drag latency. Setting `setPointerCapture` ahead of `startResizeDragging` does not help once the compositor has taken over the pointer grab. Verified via Context7 docs + Codex diagnosis — Linux frameless is a known-fragile path.
**Fix:**
- Linux uses **native KWin/Mutter decorations**. `src-tauri/tauri.linux.conf.json` overlays `decorations: true` + full main window config (title, sizes) — overlays **replace** the windows array, so every field must be present, not just the delta. `src-tauri/src/commands/windows.rs` uses `cfg!(target_os = "linux")` to set decorations per window.
- macOS / Windows keep custom chrome. `src/lib/utils/osInfo.js` `isLinux()` gates `<Titlebar>` and `<ResizeHandles>` via `useCustomChrome = $state(false)`; flips to `!isLinux()` after `loadOsInfo()` resolves.
- Dueling drag-region handlers removed across Titlebar, float page, viewer page — everywhere a manual `startDragging()` lives, the `data-tauri-drag-region` attribute was deleted (they're alternatives per Tauri docs, not combinable).
- `ResizeHandles` kept for macOS/Windows frameless: 12 px edges / 20 px corners via CSS vars (`--kon-resize-edge`, `--kon-resize-corner`), `pointerdown` + `setPointerCapture`, corners with explicit higher z-index. Handles rendered as siblings of the animated layout div so `position: fixed` is viewport-relative rather than captured by the transform containing block.
### Window minimum sizes (evidence-backed)
Research pass cited GNOME HIG (1024×600 desktop / 360×294 mobile floors), WCAG 2.2 SC 1.4.10 Reflow (320 CSS px), Raycast 750×474 as a reference for single-pane working width, and consistent A11y principle that nothing should clip in the default configuration.
| Window | Was | Now | Rationale |
|---|---|---|---|
| Main | 1020×540 | **960×600** | Fits 210 px sidebar + ~750 px content; GNOME vertical floor. |
| Float | 400×400 | **360×480** | 360 = GNOME mobile floor; 480 fits pills + quick-add + sort + ~6 task rows without scroll. |
| Transcript editor | 450×500 | **560×520** | Exceeds WCAG reflow floor; ~60-70 char measure for editing. |
### Microphone picker cleanup
- ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown.
- `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias.
- `crates/audio/src/capture.rs` reads `/proc/asound/cards` and populates a new `description` field on `DeviceInfo` with the card's full product string (e.g. "Blue Microphones" for Jake's Yeti). Frontend prefers description → CARD=X short name → raw name.
### GPU reporting
- `commands/models.rs::get_runtime_capabilities` was hardcoded to `accelerators: vec!["cpu"]` and `supports_gpu: false` for whisper. Updated to `["cpu", "vulkan"]` and whisper `supports_gpu: true`, reflecting that `crates/transcription/Cargo.toml` links transcribe-rs with the `whisper-vulkan` feature unconditionally.
- Settings now shows the Vulkan option instead of the "This build is CPU-only" notice.
### Desktop shortcut
- `~/Desktop/Kon.desktop` launcher with the 128×128 icon, `Terminal=true` so logs are visible and Ctrl+C cleanly stops the run.sh wrapper.
## What's deferred
- **Transparent windows (`transparent: true`)** — Tauri issue #13270 reports this smooths drag/resize further on Linux, but it's moot now that Linux uses native decorations.
- **File-system export (.md save dialog)** — currently clipboard-only. Needs a Rust `write_text_file` command for plugin-less file writes.
- **Bulk select + bulk export** in History.
- **LLM-powered content tags** (`topic:*`, `intent:*`) — slots into Task 7 `kon-llm` stub once Phase 3 wires real llama-cpp-2.
- **Settings UX overhaul** — Jake flagged that current settings feel overwhelming. Proposed: bunch high-traffic settings, hide advanced behind a toggle. Brainstorm + plan deferred to a dedicated session.
- **Task 7 (MicroSteps end-to-end)** — storage + Tauri CRUD + kon-llm stub + frontend dual-write all landed in an earlier commit chain. The MicroSteps UI was written as the final task 7 step but not yet dogfooded against the stub LLM. Needs manual walkthrough.
## Gotchas discovered today
| Issue | Fix |
|---|---|
| `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta |
| `data-tauri-drag-region` + manual `startDragging()` on the same node caused drag latency | Pick one — we use manual `startDragging` for the button/input early-return logic |
| Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path |
| `animate-float-enter` on the viewer/float layout root created a containing block that broke `position: fixed` on ResizeHandles children | Render ResizeHandles as a sibling of the animated div, not a descendant |
| Kon binary auto-respawned on file-save while a second run.sh was also launching → two visible instances sharing one Vite server | Do not script `./run.sh` while the user has already launched via the desktop icon; rely on HMR |
| `run.sh` leaves `"beforeDevCommand": ""` in tauri.conf.json if its cleanup trap is bypassed (e.g. SIGKILL) | Cleanup trap restores `"npm run dev"` on graceful exit; SIGTERM (not SIGKILL) is the right kill signal |
| `/proc/asound/cards` header lines have leading whitespace for 2-digit card ID alignment | Parser trims leading whitespace before checking for leading digit |
## How to resume
```
Picking up Kon dogfooding from 2026/04/19.
HANDOVER is at HANDOVER.md in the project root.
Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps
dogfood with kon-llm stub, (3) Settings UX brainstorm.
```

View File

@@ -1,97 +1,99 @@
---
name: handover-2026-04-19
name: handover-2026-04-23
type: reference
tags: [handover, session, kon]
description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome
tags: [handover, session, kon, consolidation]
description: Session handover — 2026/04/23 branch consolidation + main-in-its-best-state pass
---
# Kon Handover — 2026/04/19
# Kon Handover — 2026/04/23
Second dogfood sprint. Four phases: (1) fix bugs surfaced on first real use, (2) redesign History for cognitive-load hygiene, (3) resolve broken window resize/drag on Linux Wayland, (4) clean up microphone picker.
Consolidation session. Jake asked for "all branches to main and the repo in its best state" ahead of Corbie rebrand. This session brought outstanding Dependabot PRs into `main`, cleaned workspace lints, recovered an orphan stash onto a dedicated branch, and established a clean baseline for the post-rebrand push.
## Rebrand note
The product is in the process of being renamed **Kon → Corbie**. As of 2026/04/23 the canonical product name in any new copy (grant applications, landing pages) is **Corbie** (sentence case). The GitHub repo `jakejars/kon` and the Gitea repo `jake/kon` both still carry the `kon` name, intentionally — repo rename is pending Jake's own hand and is not blocking the rebrand copy-wise. Code paths (`crates/`, `src-tauri/`, package name `kon@0.1.0`) remain as-is until the repo rename lands; renaming the codebase identifiers is a separate coordinated sweep. See `memory/project_corbie_rebrand.md` in CORBEL-Main for the full rebrand state.
## What shipped this session
### Cross-window preferences sync
- `preferences.svelte.js` emits `kon:preferences-changed` Tauri event on update.
- Main / viewer / float layouts listen and call `applyExternalPreferences` without re-emit, so theme and font changes propagate live across sibling windows.
- Echo suppressed via source window label check.
### Baseline validation
- Fresh `cargo build`, `cargo test --workspace`, `cargo fmt --check`, `npm run check`, `npm run build` run against the pre-consolidation tip (`9b0067b`, tagged as `pre-consolidation-2026-04-23` for recovery).
- `npm run build` initially failed with a missing-dependency error for `@chenglou/pretext`. Root cause: stale partial `node_modules` install dated 2026-04-21 07:45 — the directory for the package existed but was empty. `npm install` recovered it; package is present in lockfile and needed by `src/lib/utils/textMeasure.ts`.
- `cargo clippy -- -D warnings` initially failed with 3 errors in `crates/storage/src/file_storage.rs` (two doc-list overindentations, one needless return). These were trivial style issues that clippy had never enforced at CI strictness.
### Hotkey recorder
- Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee.
- Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels.
### Workspace clippy cleanup (commit `fe61661`)
- Applied `cargo clippy --fix` across 11 files: `crates/audio`, `crates/hotkey`, `crates/storage`, `crates/transcription`, `src-tauri`.
- Net -2 lines. No behavioural changes — pure lint cleanup (needless returns, unnecessary casts, `iter().any()``contains()`, `repeat().take()``repeat_n`, lifetime elision, `map_or` simplification, reference-immediately-dereferenced).
- One remaining warning left untouched: `needless_range_loop` at `src-tauri/src/commands/live.rs:1089` — clippy's suggested iterator rewrite would make it less readable. Earmarked for a focused refactor session.
- Build + 245 workspace tests remain green post-fix.
### History page redesign (research-backed)
- Compact row now shows the **title** (or "Untitled"), not body-preview text — metadata already lives in the row columns (date, duration, source icon).
- Expanded row gets an inline title input (replaces the old Rename prompt modal).
- **Edit** button opens the viewer window in `edit` mode (editable textarea, debounced save to localStorage + storage-event sync back to main history).
- **Export .md** copies a full YAML-frontmatter markdown document to the clipboard — paste into Obsidian.
- **Tags**: `$lib/utils/frontmatter.js` exposes `deriveAutoTags` (currently returns `[]`), `buildFrontmatter`, `serialiseFrontmatter`, `buildMarkdown`. Manual tags stored as `item.manualTags`, rendered as removable chips in the expanded row with `+ add tag` input.
- Header tag chip bar (cap 7, click to filter, × to clear), plus `tag:xyz` search syntax.
- Global **Starred** filter toggle in the History header.
- Research memo found all five previous auto-tag families redundant with existing row UI — kept the derivation hook for the post-Task-7 `topic:*` content tag from kon-llm.
- Duplicate-transcript render fix: expanded `<p>` only if compact preview actually truncated.
### Dependabot merges (commits `6579c5f`, `0b1c492`, `509b983`)
Three dev-dep bumps landed as `--no-ff` merge commits:
### Viewer / editor popout
- `/viewer` route now reads `kon_viewer_mode` from localStorage ("view" | "edit").
- Edit mode renders a plain textarea bound to `item.text`; 400ms debounced save flushes on input, final flush on `onDestroy`. Segment-specific controls (Compact, Starred) hidden in edit mode.
- Native title: **"Kon - Transcription Editor"**.
| # | Package | File changes |
|---|---|---|
| 1 | `picomatch` | `package-lock.json` only |
| 2 | `@sveltejs/kit` | `package.json` + `package-lock.json` |
| 3 | `vite` (npm_and_yarn group) | `package.json` + `package-lock.json` |
### Platform-aware window chrome (Linux fix)
**Root cause:** Tauri v2 frameless `decorations: false` on KDE Wayland + webkit2gtk does not honour diagonal corner resize (collapses `NorthEast` etc. to a single axis via GTK's `gtk_window_begin_resize_drag`), and `data-tauri-drag-region` adds noticeable drag latency. Setting `setPointerCapture` ahead of `startResizeDragging` does not help once the compositor has taken over the pointer grab. Verified via Context7 docs + Codex diagnosis — Linux frameless is a known-fragile path.
After the three merges: `npm install` clean, `npm run build` green, `npm run check` 0 errors / 0 warnings. Vulnerability count went from 6 (1 low / 2 moderate / 3 high) to 5 (3 low / 2 moderate) — the three highs cleared.
**Fix:**
- Linux uses **native KWin/Mutter decorations**. `src-tauri/tauri.linux.conf.json` overlays `decorations: true` + full main window config (title, sizes) — overlays **replace** the windows array, so every field must be present, not just the delta. `src-tauri/src/commands/windows.rs` uses `cfg!(target_os = "linux")` to set decorations per window.
- macOS / Windows keep custom chrome. `src/lib/utils/osInfo.js` `isLinux()` gates `<Titlebar>` and `<ResizeHandles>` via `useCustomChrome = $state(false)`; flips to `!isLinux()` after `loadOsInfo()` resolves.
- Dueling drag-region handlers removed across Titlebar, float page, viewer page — everywhere a manual `startDragging()` lives, the `data-tauri-drag-region` attribute was deleted (they're alternatives per Tauri docs, not combinable).
- `ResizeHandles` kept for macOS/Windows frameless: 12 px edges / 20 px corners via CSS vars (`--kon-resize-edge`, `--kon-resize-corner`), `pointerdown` + `setPointerCapture`, corners with explicit higher z-index. Handles rendered as siblings of the animated layout div so `position: fixed` is viewport-relative rather than captured by the transform containing block.
### Orphan-stash recovery (branch `feat/design-system-recover`, commit `8855db8`)
- A WIP stash sat on `stash@{0}`, pinned to parent commit `1296173` — the tip of the deleted `feat/design-system` branch. ~1000 LOC across 24 files: `ai-formatting/rule_based.rs` rewrite, `transcription/local_engine.rs`, core `hardware.rs` + `recommendation.rs`, `audio/resample.rs`, and a sweep across Svelte UI (`TaskSidebar`, `FilesPage`, `TasksPage`, `WipTaskList`, `ModelDownloader`, `Titlebar`, `viewer`, `float`).
- Recovered using `git stash branch`, which creates a branch from the stash's parent commit and applies the stash content. Parent commit still existed in the reflog even though no branch referenced it.
- Committed the full recovery as `wip(design-system): recover orphan stash` on the new branch. **Not merged to main.** Needs a focused rebase session: heavy conflicts expected in `TasksPage`, `FilesPage`, `ai-formatting/rule_based.rs`, and `transcription/local_engine.rs` against current main since those all saw release-blocker fixes after the stash was taken. Also missing the 2026-04-23 clippy cleanup.
- Branch pushed to `github` for remote preservation.
### Window minimum sizes (evidence-backed)
Research pass cited GNOME HIG (1024×600 desktop / 360×294 mobile floors), WCAG 2.2 SC 1.4.10 Reflow (320 CSS px), Raycast 750×474 as a reference for single-pane working width, and consistent A11y principle that nothing should clip in the default configuration.
## Release-blocker state (unchanged this session)
| Window | Was | Now | Rationale |
|---|---|---|---|
| Main | 1020×540 | **960×600** | Fits 210 px sidebar + ~750 px content; GNOME vertical floor. |
| Float | 400×400 | **360×480** | 360 = GNOME mobile floor; 480 fits pills + quick-add + sort + ~6 task rows without scroll. |
| Transcript editor | 450×500 | **560×520** | Exceeds WCAG reflow floor; ~60-70 char measure for editing. |
From `docs/issues/README.md`:
### Microphone picker cleanup
- ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown.
- `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias.
- `crates/audio/src/capture.rs` reads `/proc/asound/cards` and populates a new `description` field on `DeviceInfo` with the card's full product string (e.g. "Blue Microphones" for Jake's Yeti). Frontend prefers description → CARD=X short name → raw name.
- **0 open CRITICAL**
- **1 open MAJOR** — RB-08 `power-assertion-macos-objc2` (awaits manual runtime verification on a real macOS machine: `pmset -g assertions` during a background live-session)
- **11 RBs resolved**
- **11 CR items resolved** from the 2026-04-22 code review
### GPU reporting
- `commands/models.rs::get_runtime_capabilities` was hardcoded to `accelerators: vec!["cpu"]` and `supports_gpu: false` for whisper. Updated to `["cpu", "vulkan"]` and whisper `supports_gpu: true`, reflecting that `crates/transcription/Cargo.toml` links transcribe-rs with the `whisper-vulkan` feature unconditionally.
- Settings now shows the Vulkan option instead of the "This build is CPU-only" notice.
RB-08 continues to gate v0.1 tagging.
### Desktop shortcut
- `~/Desktop/Kon.desktop` launcher with the 128×128 icon, `Terminal=true` so logs are visible and Ctrl+C cleanly stops the run.sh wrapper.
## Post-consolidation follow-up (evening 2026/04/23)
## What's deferred
Jake asked for "nothing outstanding" on Corbie. Follow-up pass did:
- **Transparent windows (`transparent: true`)** — Tauri issue #13270 reports this smooths drag/resize further on Linux, but it's moot now that Linux uses native decorations.
- **File-system export (.md save dialog)** — currently clipboard-only. Needs a Rust `write_text_file` command for plugin-less file writes.
- **Bulk select + bulk export** in History.
- **LLM-powered content tags** (`topic:*`, `intent:*`) — slots into Task 7 `kon-llm` stub once Phase 3 wires real llama-cpp-2.
- **Settings UX overhaul** — Jake flagged that current settings feel overwhelming. Proposed: bunch high-traffic settings, hide advanced behind a toggle. Brainstorm + plan deferred to a dedicated session.
- **Task 7 (MicroSteps end-to-end)** — storage + Tauri CRUD + kon-llm stub + frontend dual-write all landed in an earlier commit chain. The MicroSteps UI was written as the final task 7 step but not yet dogfooded against the stub LLM. Needs manual walkthrough.
### 1. npm audit triaged + safe minor bumps landed
- `@sveltejs/kit` 2.57.1 → **2.58.0**, `@sveltejs/adapter-static` 3.0.6 → **3.0.10** (commit `0e18a78`). Both are patch / minor SemVer — no API breakage.
- **Residual advisories accepted** (5 total: 3 low / 2 moderate, all transitive, not session-actionable):
- **cookie@0.6.x** (3× low) pinned by `@sveltejs/kit@^0.6.0` as of kit 2.58.0. Upstream has not bumped the pin; fixed cookie version is `0.7+`. Advisory: out-of-bounds chars in name/path/domain. **Context**: Kon/Corbie is a Tauri desktop app, no public-facing HTTP server, so the attack surface for this is effectively nil. Re-triage when `@sveltejs/kit` lands a cookie bump.
- **esbuild via svelte-i18n@4.0.1** (2× moderate) — `svelte-i18n` bundles `esbuild@^0.19.2` for its CLI. Advisory: "dev-server allows any website to read responses". **Context**: only affects `vite dev` over the network. In a Tauri desktop app the dev server is localhost-only and not relevant to production. `svelte-i18n` is already at latest (4.0.1); the only way to drop this is replace the i18n library.
- `npm audit --audit-level high` would report 0 vulnerabilities; GitHub's richer advisory db may still show the transitive set but none are actionable without upstream bumps.
## Gotchas discovered today
### 2. needless_range_loop refactor landed
Commit `2b82b9b``src-tauri/src/commands/live.rs:1087` duplicate-merge inner loop rewritten as `for segment in &nearby[start..upper]`. Workspace now **zero clippy warnings** with `cargo clippy --workspace --all-targets`. Safe to turn on `-D warnings` in CI when desired.
| Issue | Fix |
|---|---|
| `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta |
| `data-tauri-drag-region` + manual `startDragging()` on the same node caused drag latency | Pick one — we use manual `startDragging` for the button/input early-return logic |
| Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path |
| `animate-float-enter` on the viewer/float layout root created a containing block that broke `position: fixed` on ResizeHandles children | Render ResizeHandles as a sibling of the animated div, not a descendant |
| Kon binary auto-respawned on file-save while a second run.sh was also launching → two visible instances sharing one Vite server | Do not script `./run.sh` while the user has already launched via the desktop icon; rely on HMR |
| `run.sh` leaves `"beforeDevCommand": ""` in tauri.conf.json if its cleanup trap is bypassed (e.g. SIGKILL) | Cleanup trap restores `"npm run dev"` on graceful exit; SIGTERM (not SIGKILL) is the right kill signal |
| `/proc/asound/cards` header lines have leading whitespace for 2-digit card ID alignment | Parser trims leading whitespace before checking for leading digit |
### 3. `feat/design-system-recover` evaluated, WIP layer abandoned
Compared the branch against current main:
- The handoff commit's content (design tokens, fonts, previews, ui_kits) is **already on main** as commit `8ba5641`. Recovery branch just duplicates it from a different base.
- The WIP layer on top modifies 24 files (TasksPage, FilesPage, WipTaskList, TaskSidebar, viewer, float, local_engine, rule_based, etc.) against a base main has since significantly refactored (RB-01 through RB-12 + all the CR-2026-04-22 fixes + a preferences.svelte.js → .ts conversion).
- Test merge surfaces **14+ conflicts** needing per-file, designer-intent-aware resolution. Conflict cost > value of the WIP given main has already moved past most of it.
- **Decision: abandon the WIP**. Local branch deleted. `github/feat/design-system-recover` preserved as a read-only archive for reference mining. Any design-system polish from here gets redone fresh against current main rather than rebased through a stale WIP.
## How to resume
## Still deferred (not session-actionable)
```
Picking up Kon dogfooding from 2026/04/19.
HANDOVER is at HANDOVER.md in the project root.
Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps
dogfood with kon-llm stub, (3) Settings UX brainstorm.
```
- **RB-08 verification** — needs macOS hardware access. Gates v0.1 tag. One concrete open item.
- **Kon → Corbie codebase rename** — Jake handles repo rename (Gitea + GitHub); coordinated codebase sweep (package name `kon@0.1.0``corbie@0.1.0`, crate prefixes `kon-*``corbie-*`, desktop file, binary name, install paths `~/.local/share/kon/``~/.local/share/corbie/`, database filename `kon.db`, window titles, README body) should follow the repo rename so artefact names match. Will need a migration shim to keep existing users' data alive across the path change.
- **npm audit residuals** — see §1 above. Re-visit when `@sveltejs/kit` bumps its cookie pin, or when an i18n replacement is chosen.
- **CI clippy enforcement** — workspace is now clean with default clippy. Turning on `-D warnings` in CI costs nothing additional.
- **Transparent windows**, **file-system export**, **bulk select/export**, **LLM content tags**, **Settings UX overhaul**, **Task 7 walk-through** — all carried from 2026-04-19 handover.
## Repo state at end of follow-up
- `main` at `2b82b9b` (evening pass: +3 commits — sveltejs bumps, clippy refactor, this handover update)
- Local branches: `main` only (recovery branch deleted locally after archival)
- `github/feat/design-system-recover` at `8855db8` (preserved as archive)
- Tag `pre-consolidation-2026-04-23` at `9b0067b`
- `cargo build --workspace` ✓ / `cargo test --workspace` ✓ (245 passing) / `cargo clippy --workspace --all-targets` **0 warnings** / `cargo fmt --check` ✓ / `npm run check` ✓ (0 errors, 0 warnings) / `npm run build`
## Anchors
- Previous handover: [HANDOVER-2026-04-19.md](HANDOVER-2026-04-19.md)
- Release-blocker index: [docs/issues/README.md](docs/issues/README.md)
- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_corbie_rebrand.md`
- Active-focus upstream: `context/active-focus.md` in CORBEL-Main

View File

@@ -11,7 +11,7 @@ Kon is a local-first, cognitive-load-aware dictation and task-capture desktop ap
**Pre-alpha.** Actively dogfooded on Linux (KDE Plasma 6 on Wayland). macOS and Windows targets are in scope and exercised by CI, but not yet beta-ready. One primary user; open source-intent with licence TBD before public beta.
- Current `main`: see commit log
- 136 automated lib tests across 10 crates, all passing
- 245 automated lib tests across 10 crates, all passing
- Cross-platform CI (Linux / macOS / Windows) via GitHub Actions
---
@@ -288,7 +288,7 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml`
### Testing
```bash
cargo test --workspace --lib # 136 tests across 10 crates
cargo test --workspace --lib # 245 tests across 10 crates
npm run check # svelte-check (type-checks .svelte files)
cargo check --workspace --all-targets
```

View File

@@ -112,7 +112,7 @@ impl MicrophoneCapture {
for device in devices {
let name = device_display_name(&device).unwrap_or_else(|| "<unnamed>".to_string());
let (sample_rate, channels) = match device.default_input_config() {
Ok(cfg) => (cfg.sample_rate(), cfg.channels() as u16),
Ok(cfg) => (cfg.sample_rate(), cfg.channels()),
Err(_) => (0, 0),
};
let is_likely_monitor = is_monitor_name(&name);
@@ -277,11 +277,7 @@ fn device_display_name(device: &cpal::Device) -> Option<String> {
/// `pipewire` / `default` → `None`
fn extract_card_id(name: &str) -> Option<&str> {
let rest = name.split("CARD=").nth(1)?;
Some(
rest.split(|c: char| c == ',' || c == ';')
.next()
.unwrap_or(rest),
)
Some(rest.split([',', ';']).next().unwrap_or(rest))
}
/// Read `/proc/asound/cards` and return a map from ALSA card short name
@@ -361,7 +357,7 @@ fn open_and_validate(
.default_input_config()
.map_err(|e| KonError::AudioCaptureFailed(format!("default_input_config: {e}")))?;
let sample_rate = config.sample_rate();
let channels = config.channels() as u16;
let channels = config.channels();
let format = config.sample_format();
eprintln!(

View File

@@ -343,14 +343,14 @@ async fn device_listener(
fn is_event_device(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map_or(false, |n| n.starts_with("event"))
.is_some_and(|n| n.starts_with("event"))
}
/// Return true when the device's reported key set includes the combo's
/// configured trigger key. A device that reports no keys at all (for
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
supported.is_some_and(|keys| keys.contains(Key::new(combo.key_code)))
}
#[cfg(test)]

View File

@@ -240,11 +240,30 @@ impl LlmEngine {
}
pub fn decompose_task(&self, task_text: &str) -> Result<Vec<String>, EngineError> {
self.decompose_task_with_feedback(task_text, &[])
}
/// Same as `decompose_task` but allows callers to pass recent HITL
/// feedback rows so the system prompt gets conditioned on the
/// user's preferred decomposition style. The `examples` vec is
/// rendered into a few-shot block appended to the base system
/// prompt by `prompts::build_conditioned_system_prompt`.
///
/// Callers should pass most-recent-first; older examples still
/// participate but weigh less because of their position in the
/// prompt. Empty slice keeps behaviour identical to `decompose_task`.
pub fn decompose_task_with_feedback(
&self,
task_text: &str,
examples: &[prompts::FeedbackExample],
) -> Result<Vec<String>, EngineError> {
let model = self.loaded_model_arc()?;
let system =
prompts::build_conditioned_system_prompt(prompts::DECOMPOSE_TASK_SYSTEM, examples);
let prompt = render_chat_prompt(
&model,
&[
("system", prompts::DECOMPOSE_TASK_SYSTEM),
("system", system.as_str()),
("user", &format!("Task: {task_text}")),
],
)?;
@@ -261,15 +280,27 @@ impl LlmEngine {
}
pub fn extract_tasks(&self, transcript: &str) -> Result<Vec<String>, EngineError> {
self.extract_tasks_with_feedback(transcript, &[])
}
/// Feedback-conditioned variant of `extract_tasks`. See
/// `decompose_task_with_feedback` for the `examples` semantics.
pub fn extract_tasks_with_feedback(
&self,
transcript: &str,
examples: &[prompts::FeedbackExample],
) -> Result<Vec<String>, EngineError> {
if transcript.trim().is_empty() {
return Ok(Vec::new());
}
let model = self.loaded_model_arc()?;
let system =
prompts::build_conditioned_system_prompt(prompts::EXTRACT_TASKS_SYSTEM, examples);
let prompt = render_chat_prompt(
&model,
&[
("system", prompts::EXTRACT_TASKS_SYSTEM),
("system", system.as_str()),
("user", &format!("Transcript:\n{transcript}")),
],
)?;

View File

@@ -10,3 +10,113 @@ output a JSON array of action items the speaker committed to. Each item must \
be a short imperative sentence. Omit observations, wishes, and background \
context that are not explicit commitments. Output an empty array if there are \
no action items.";
/// Compact representation of a human-in-the-loop feedback example used
/// for few-shot prompt conditioning. Built by kon-storage and fed to the
/// prompt builder below; we keep this struct local to the LLM crate so
/// kon-llm does not depend on kon-storage.
#[derive(Debug, Clone)]
pub struct FeedbackExample {
/// What the AI was given as input (e.g. the parent task text, or
/// the transcript chunk). Kept verbatim.
pub input: String,
/// What the AI produced originally. `None` if the user only
/// gave a thumbs-up without a prior edit (positive signal
/// without a paired correction).
pub original_output: Option<String>,
/// What the user changed it to. `None` for thumbs-only rows.
/// This is the highest-value signal — when present, inject it
/// as the "good" output in the few-shot example.
pub corrected_output: Option<String>,
}
/// Render a feedback example into the exemplar block used in prompt
/// conditioning. Returns `None` for rows that carry no usable pairing
/// (e.g. a thumbs-up with no input context).
fn render_feedback_exemplar(ex: &FeedbackExample) -> Option<String> {
if ex.input.trim().is_empty() {
return None;
}
let good = ex
.corrected_output
.as_deref()
.or(ex.original_output.as_deref())?;
let good = good.trim();
if good.is_empty() {
return None;
}
Some(format!("Input: {}\nGood output: {}", ex.input.trim(), good))
}
/// Build a system prompt that combines the base task system prompt
/// with a few-shot block assembled from recent HITL examples. If no
/// usable examples are available, returns the base prompt unchanged
/// so early users see the generic behaviour and the LLM is not
/// confused by an empty exemplar section.
///
/// The exemplars are ordered most-recent-first (caller's order is
/// preserved) so the LLM weights the user's current style over
/// earlier noise, mirroring what a human reviewer would do.
pub fn build_conditioned_system_prompt(base: &str, examples: &[FeedbackExample]) -> String {
let rendered: Vec<String> = examples
.iter()
.filter_map(render_feedback_exemplar)
.collect();
if rendered.is_empty() {
return base.to_string();
}
let block = rendered
.iter()
.map(|s| format!("- {s}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"{base}\n\nHere are examples of the style this user prefers, in the \
user's own words. Match this style closely when producing your output:\n{block}"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_plain_prompt_when_no_examples() {
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &[]);
assert_eq!(out, DECOMPOSE_TASK_SYSTEM);
}
#[test]
fn skips_empty_input_examples() {
let examples = vec![FeedbackExample {
input: String::new(),
original_output: None,
corrected_output: Some("ignored".into()),
}];
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
assert_eq!(out, DECOMPOSE_TASK_SYSTEM);
}
#[test]
fn prefers_corrected_over_original() {
let examples = vec![FeedbackExample {
input: "Clean room".into(),
original_output: Some("Organise your bedroom".into()),
corrected_output: Some("Pick up one shirt from the floor".into()),
}];
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
assert!(out.contains("Pick up one shirt from the floor"));
assert!(!out.contains("Organise your bedroom"));
}
#[test]
fn falls_back_to_original_when_no_correction() {
let examples = vec![FeedbackExample {
input: "Write report".into(),
original_output: Some("Open a blank document".into()),
corrected_output: None,
}];
let out = build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, &examples);
assert!(out.contains("Open a blank document"));
}
}

View File

@@ -889,6 +889,151 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<Err
.collect())
}
// --- Feedback (HITL) -------------------------------------------------------
//
// Phase 2 of the feature-complete roadmap: capture thumbs + corrections on
// AI-generated output so the prompt builder can inject recent examples as
// few-shot exemplars. Storage-only here; the prompt-conditioning logic lives
// in kon-llm. Retrieval returns the most recent rows, narrowed to the
// active profile when provided so feedback does not cross profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedbackTargetType {
MicroStep,
TaskExtraction,
Cleanup,
}
impl FeedbackTargetType {
pub fn as_str(self) -> &'static str {
match self {
FeedbackTargetType::MicroStep => "microstep",
FeedbackTargetType::TaskExtraction => "task_extraction",
FeedbackTargetType::Cleanup => "cleanup",
}
}
/// Parse the database `target_type` string back into the enum.
/// Named `parse` rather than `from_str` so it does not collide with
/// the `std::str::FromStr` trait — the trait is overkill here
/// because callers never want a `FromStr::Err` and already know the
/// set of valid values at the call site.
pub fn parse(s: &str) -> Option<Self> {
match s {
"microstep" => Some(FeedbackTargetType::MicroStep),
"task_extraction" => Some(FeedbackTargetType::TaskExtraction),
"cleanup" => Some(FeedbackTargetType::Cleanup),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct RecordFeedbackParams {
pub target_type: FeedbackTargetType,
pub target_id: Option<String>,
/// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
pub rating: i8,
pub original_text: Option<String>,
pub corrected_text: Option<String>,
pub context_json: Option<String>,
pub profile_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FeedbackRow {
pub id: i64,
pub target_type: String,
pub target_id: Option<String>,
pub rating: i64,
pub original_text: Option<String>,
pub corrected_text: Option<String>,
pub context_json: Option<String>,
pub profile_id: String,
pub created_at: String,
}
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
if !matches!(params.rating, -1..=1) {
return Err(KonError::StorageError(format!(
"invalid feedback rating {} (must be -1, 0, or 1)",
params.rating
)));
}
let profile_id = params
.profile_id
.unwrap_or_else(|| crate::DEFAULT_PROFILE_ID.to_string());
let row = sqlx::query(
"INSERT INTO feedback (
target_type, target_id, rating,
original_text, corrected_text, context_json, profile_id
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(params.target_type.as_str())
.bind(params.target_id)
.bind(params.rating as i64)
.bind(params.original_text)
.bind(params.corrected_text)
.bind(params.context_json)
.bind(profile_id)
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("record_feedback failed: {e}")))?;
Ok(row.get::<i64, _>("id"))
}
/// Fetch the most recent feedback rows for a given target type, scoped to
/// the active profile. Used by the prompt builder to gather few-shot
/// exemplars. Orders by `created_at DESC` so the most recent corrections
/// outweigh older ones — the user's style drifts, and we want the LLM
/// to track the current preference.
///
/// `min_rating` filters out thumbs-down examples when the caller only
/// wants positive reinforcement; pass `-1` to include everything.
pub async fn list_feedback_examples(
pool: &SqlitePool,
target_type: FeedbackTargetType,
limit: i64,
min_rating: i8,
profile_id: Option<&str>,
) -> Result<Vec<FeedbackRow>> {
let pid = profile_id.unwrap_or(crate::DEFAULT_PROFILE_ID);
let rows = sqlx::query(
"SELECT id, target_type, target_id, rating,
original_text, corrected_text, context_json,
profile_id, created_at
FROM feedback
WHERE target_type = ?
AND profile_id = ?
AND rating >= ?
ORDER BY created_at DESC
LIMIT ?",
)
.bind(target_type.as_str())
.bind(pid)
.bind(min_rating as i64)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("list_feedback_examples failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| FeedbackRow {
id: r.get("id"),
target_type: r.get("target_type"),
target_id: r.get("target_id"),
rating: r.get("rating"),
original_text: r.get("original_text"),
corrected_text: r.get("corrected_text"),
context_json: r.get("context_json"),
profile_id: r.get("profile_id"),
created_at: r.get("created_at"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -2,11 +2,11 @@ use std::path::PathBuf;
/// Resolve the per-user app data directory, following each OS's convention:
///
/// - Windows: `%LOCALAPPDATA%\kon\` e.g. `C:\Users\Jake\AppData\Local\kon`
/// - Windows: `%LOCALAPPDATA%\kon\` e.g. `C:\Users\Jake\AppData\Local\kon`
/// - macOS: `~/Library/Application Support/Kon/`
/// - Linux: `$XDG_DATA_HOME/kon` or `~/.local/share/kon` (XDG Base Directory),
/// with a fallback to the legacy `~/.kon/` if it already exists, so
/// existing installs keep working.
/// with a fallback to the legacy `~/.kon/` if it already exists, so
/// existing installs keep working.
/// - Other Unix: `~/.kon/`
///
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
@@ -45,7 +45,7 @@ pub fn app_data_dir() -> PathBuf {
return PathBuf::from(xdg).join("kon");
}
}
return PathBuf::from(home).join(".local").join("share").join("kon");
PathBuf::from(home).join(".local").join("share").join("kon")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]

View File

@@ -10,10 +10,11 @@ pub use database::{
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript,
get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task,
insert_transcript, list_profile_terms, list_profiles, list_recent_errors, list_subtasks,
list_tasks, list_transcripts, list_transcripts_paged, log_error, search_transcripts,
set_setting, uncomplete_task, update_profile, update_task, update_transcript,
update_transcript_meta, ErrorLogRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
insert_transcript, list_feedback_examples, list_profile_terms, list_profiles,
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
log_error, record_feedback, search_transcripts, set_setting, uncomplete_task, update_profile,
update_task, update_transcript, update_transcript_meta, ErrorLogRow, FeedbackRow,
FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams,
TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};

View File

@@ -334,6 +334,49 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
FROM transcripts;
"#,
),
(
10,
"feedback: HITL thumbs + correction capture",
r#"
-- Feedback rows capture human-in-the-loop signal on AI-generated
-- output. Two flavours bundled into one table:
-- - thumbs (rating = -1 | +1, original_text optional, corrected_text NULL)
-- - correction (rating defaults to +1, original_text + corrected_text present)
--
-- `target_type` names the producing surface:
-- 'microstep' — subtask decomposition from DECOMPOSE_TASK_SYSTEM
-- 'task_extraction' — tasks lifted from a transcript (EXTRACT_TASKS_SYSTEM)
-- 'cleanup' — transcript cleanup output
--
-- `target_id` is the surface-specific identifier where one exists
-- (subtask id, task id, transcript id). NULL is allowed because
-- not every feedback event has a stable target id yet.
--
-- `context_json` carries the input the AI was conditioned on
-- (parent task text, transcript chunk, etc.) so future prompt
-- builders can reconstruct the original I/O pair for few-shot
-- injection or semantic retrieval.
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL
CHECK (target_type IN ('microstep', 'task_extraction', 'cleanup')),
target_id TEXT,
rating INTEGER NOT NULL
CHECK (rating IN (-1, 0, 1)),
original_text TEXT,
corrected_text TEXT,
context_json TEXT,
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
REFERENCES profiles(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_feedback_target_type_rating
ON feedback(target_type, rating, created_at DESC);
CREATE INDEX idx_feedback_profile
ON feedback(profile_id, target_type, created_at DESC);
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -483,7 +526,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 9);
assert_eq!(count, 10);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -502,7 +545,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 9);
assert_eq!(count, 10);
}
#[tokio::test]
@@ -859,8 +902,11 @@ mod tests {
// The poisoned migration below first creates `poison_marker`
// (syntactically valid, would succeed against any SQLite) and then
// runs a guaranteed-invalid function call. Under the new atomic
// implementation, neither `poison_marker` nor the v9 row should
// implementation, neither `poison_marker` nor the poison row should
// survive the failed call.
//
// Version number must sit above the real MIGRATIONS max so the
// baseline migrate cleanly finishes first.
#[tokio::test]
async fn multi_statement_migration_rolls_back_on_failure() {
let pool = SqlitePoolOptions::new()
@@ -871,8 +917,18 @@ mod tests {
run_migrations(&pool).await.expect("baseline migrate");
const POISON: &[(i64, &str, &str)] = &[(
10,
// Discover the real max version so the poison migration is
// always exactly one past the end of MIGRATIONS, regardless of
// how many real migrations we add in future.
let real_max: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
let poison_version = real_max + 1;
let poison: &[(i64, &str, &str)] = &[(
poison_version,
"rb-02 atomicity poison",
r#"
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
@@ -880,7 +936,7 @@ mod tests {
"#,
)];
let result = run_migrations_slice(&pool, POISON).await;
let result = run_migrations_slice(&pool, poison).await;
assert!(
result.is_err(),
"poisoned migration must return Err, got: {result:?}"
@@ -896,14 +952,14 @@ mod tests {
"poison_marker must not exist; got: {marker:?}"
);
// `schema_version` must not include v10 — version insert is part
// of the same transaction that rolled back.
// `schema_version` must not include the poison version — version
// insert is part of the same transaction that rolled back.
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
assert_eq!(
max, 9,
max, real_max,
"schema_version must not advance past the failed migration"
);
}

View File

@@ -158,7 +158,7 @@ mod tests {
let mut total_pushed: u64 = 0;
let tentative_per_cycle: u64 = 200;
for _ in 0..100 {
buf.extend(std::iter::repeat(0.25_f32).take(16_000));
buf.extend(std::iter::repeat_n(0.25_f32, 16_000));
total_pushed += 16_000;
let commit_point = total_pushed - tentative_per_cycle;
start = trim_buffer_to_commit_point(&mut buf, start, commit_point);
@@ -199,7 +199,7 @@ mod tests {
// Simulate a capture buffer that has received 1.2 s of audio
// starting at t=0.
let mut buf: Vec<f32> = std::iter::repeat(0.1_f32).take(19_200).collect();
let mut buf: Vec<f32> = std::iter::repeat_n(0.1_f32, 19_200).collect();
let new_start = trim_buffer_to_commit_point(&mut buf, 0, commit_idx);
assert_eq!(new_start, 8_000);
assert_eq!(buf.len(), 19_200 - 8_000);

View File

@@ -306,7 +306,7 @@ impl VadChunker for RmsVadChunker {
.saturating_sub(self.pending.len() as u64);
let pad_len = FRAME_SAMPLES - self.pending.len();
let mut padded = std::mem::take(&mut self.pending);
padded.extend(std::iter::repeat(0.0_f32).take(pad_len));
padded.extend(std::iter::repeat_n(0.0_f32, pad_len));
if let Some(chunk) = self.consume_frame(padded, frame_start) {
emitted.push(chunk);
}

View File

@@ -0,0 +1,267 @@
---
name: Corbie — feature-complete roadmap
description: Build plan from 2026-04-23 baseline to full feature-complete v0.1 release
type: roadmap
tags: [roadmap, planning, corbie, release]
created: 2026/04/23
status: active
author: Wren (CORBEL's resident agent) on behalf of Jake Sames
---
# Corbie — Feature-Complete Roadmap
> **What Corbie is.** A local-first, cognitive-load-aware dictation + task-capture desktop app. Vulkan-accelerated Whisper / Parakeet speech-to-text, a local LLM (Qwen3 tiers) for transcript cleanup and task extraction, an MCP server for integration with Claude Desktop / Cline / Cursor, and a UI designed around ADHD / executive-dysfunction needs. Tauri 2 + Svelte 5 + Rust. Zero telemetry.
>
> **Formerly known as Kon.** Rebrand in flight; repo names at `jakejars/kon` + `git.corbel.consulting/jake/kon` still carry the Kon name and will rename together with the codebase sweep in the final phase.
## Baseline — where we are (2026/04/23)
**Core MVP (from `docs/brief/feature-set.md`):** 9 of 10 complete.
One gap: **visual time representation** (the spec's "#1 community-requested feature" — shrinking colour disks / progress rings, externalising time passage). The rest — local transcription, auto-populating tasks, WIP limits, history + search, light/dark theming, templates, vocabulary profiles, file upload, open-format markdown export — all shipped.
**Post-MVP (designed, not yet prioritised):** 1 of 9 complete.
MicroSteps is shipped; its "just-start" timer button emits an event that currently has no listener anywhere in the codebase. The differentiating ADHD-specific features (Margot nudges, energy-aware sequencing, rituals, if-then intentions, forgiving gamification, TTS, human-in-the-loop feedback) are all documented in the brief but not started.
**Release-blockers:** 1 — RB-08 macOS power-assertion, pending manual verification on a real Mac (Jake's friend Rachmann has a Mac and can run this offline).
**Workspace state:** main is clippy-zero-warnings, 245/245 tests passing, fmt clean, svelte-check clean, npm build clean. Three dependabot bumps landed this session plus a clippy cleanup pass and a needless_range_loop refactor. One orphan design-system WIP branch parked on github as archive.
## Approach
> **Layer 1 first** (per Jake's standing rule): build the features roughly, in series, through Phase 1 Phase 8. Do polish passes in Phase 9 and QC + release in Phase 10. **Do not mix make-it-work and make-it-neat passes** — every phase ships end-to-end (event wired, UI rendered, store state committed, tests updated) but does not chase aesthetic polish until Phase 9.
> **Ordering rationale.** Phase 1 closes the Core MVP gap and unblocks the already-half-wired just-start timer. Phase 2 enables model improvement by collecting human feedback — useful even while later phases are being built. Phases 38 build the differentiating ADHD-specific features from highest-utility-per-effort to lowest. Phase 9 is polish debt. Phase 10 is release prep (including Rachmann's RB-08 verification and the Kon → Corbie codebase rename).
---
## Phase 1 — Visual countdown + Just-Start timer
**Why now.** Closes the one remaining Core MVP gap. Unblocks the dangling `kon:start-timer` emit from MicroSteps. Directly combats time blindness, which the brief names as the single biggest lever for the target audience.
**Scope.**
- `FocusTimer.svelte` — a progress-ring countdown component. Shrinking colour ring (not digital). Subtle colour shift across the last 15%. Remaining time label inside the ring for users who want the number; small enough that the ring dominates the visual field.
- `focusTimer.svelte.ts` store — single active timer, running / paused / completed state, elapsed / remaining computed, event dispatch on state transitions.
- Mount in `+layout.svelte` so the timer persists across page navigation (dictation → tasks → settings).
- Listener for `window` event `kon:start-timer` with `{ durationSeconds, label }` payload.
- Trigger from MicroSteps row (already exists). Trigger from task-row context button (new).
- Floating position top-right of main content, above everything, not intrusive when idle (hidden until a timer is running).
- On completion: gentle chime + 3-second ring flourish + OS notification via `tauri-plugin-notification`. No modal. No guilt copy.
- Store survives window close/reopen via localStorage.
**Out of scope for Phase 1.** Rhythmic voice anchoring (part of Margot, Phase 6). Custom-duration picker (fixed 2 / 5 / 10 / 15 min presets for now). Multi-timer UI.
**Acceptance.** From a MicroStep row, clicking the timer button (a) shows the ring visible somewhere on screen, (b) it counts down, (c) at 0 it fires completion + notification, (d) works across page switches, (e) survives a window close + reopen mid-countdown.
**Estimated effort.** Half day.
---
## Phase 2 — Human-in-the-loop feedback
**Why here.** AI output quality is the single biggest determinant of whether Corbie feels useful. Thumbs-up / thumbs-down on AI-generated micro-steps and task extractions costs almost nothing to add and gives us a feedback corpus the moment anyone uses it. Enables later retraining / prompt tuning.
**Scope.**
- Thumbs-up / thumbs-down buttons on every AI-generated item (micro-step row, extracted task, cleanup paragraph).
- "Edit" path already exists for text; the feedback is additive.
- `feedback` table in SQLite: `item_id`, `item_type`, `rating`, `timestamp`, optional `correction_text`.
- Rust command `record_feedback` + `list_feedback` (for later export).
- No UI surface for viewing feedback yet. Just capture. A future export pass to JSONL feeds prompt-engineering or fine-tuning.
**Out of scope.** Retraining loop, per-user profile adjustment, any UI to view feedback history.
**Acceptance.** Thumbs visible on AI-generated items. Clicking records to SQLite. `cargo test` on storage covers migration + insert + list.
**Estimated effort.** Half day to 1 day.
---
## Phase 3 — Energy-aware task sequencing
**Why here.** Next-highest-utility post-MVP feature. Replaces the cut-for-OS-reasons temptation-bundling feature. Small surface, clear user-facing outcome.
**Scope.**
- `energy` column on tasks: nullable enum `High | Medium | BrainDead`.
- Migration + Rust CRUD.
- Tag chip on task rows; tap to cycle / set.
- Sort / filter option on the tasks page: "Match my energy" → AI surfaces tasks matching a user-set current energy, falls back to `Medium` if unset.
- No automatic energy detection. Pure user input.
**Out of scope.** Time-of-day heuristics, calendar integration, AI-predicted user energy.
**Acceptance.** User tags a task, sets their current energy via a header control, tasks page filter respects it.
**Estimated effort.** 1 day.
---
## Phase 4 — Read Page Aloud (TTS)
**Why here.** Small and self-contained. Engages auditory processing which the brief specifically calls out as a retention lever for the target audience. Uses OS-native TTS (no new dependencies, no model download). Clean single-tap affordance.
**Scope.**
- Rust command `tts_speak(text: String, rate: f32, voice: Option<String>)` — platform dispatch:
- Linux: `spd-say` (speech-dispatcher is available on most distros; graceful fallback to `espeak` if missing).
- macOS: `say` (built in).
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer`.
- Small "speaker" icon on any text view (transcript viewer, micro-step list, cleanup result).
- Single-tap play; second tap stops. No pause/resume in v1.
- Settings: voice picker (populated from OS), rate slider (0.52.0).
**Out of scope.** Premium voices. Cloud TTS. Concurrent-speaking queue. SSML.
**Acceptance.** Tap speaker icon on a transcript → hear it read on Linux + expected-to-work-on macOS+Windows (test matrix in Phase 10).
**Estimated effort.** Half day.
---
## Phase 5 — Start / shutdown rituals
**Why here.** Meaningful UX but larger surface. Needs calm copy, gentle flow, and a default-off toggle because rituals can feel parental if not optional.
**Scope.**
- Morning triage: on first launch after 06:00, show a modal / dedicated page: "yesterday's incomplete tasks" (from SQLite query: `completed = false AND created_at < today`), with checkbox pick-list, and "pick 13 for today" constraint that refuses selections > 3.
- Evening shutdown: user-triggerable (not scheduled) review: "what got done today", "open loops to close", "separate work from rest" copy. No automation; ritual as reflection.
- Both off by default in settings. When off, no modal, no pressure.
- Skip-for-today button on morning triage; never shows guilt copy.
**Out of scope.** Calendar integration, automatic sleep detection, weekly / monthly reviews.
**Acceptance.** Morning modal shows correct tasks. Selecting > 3 is prevented with a gentle message. Skip works and doesn't re-prompt same day. Evening shutdown opens a reflective page, doesn't block closing the app.
**Estimated effort.** 1 2 days.
---
## Phase 6 — Soft-touch nudging (Margot protocol)
**Why here.** This is the big differentiator and it's intentionally scheduled late because it depends on the phases before it (it nudges *about* tasks, timers, rituals) and on a careful copy pass to not feel like a push notification. The brief explicitly calls out: reminders must not function as standard push notifications; they must be anticipatory guidance.
**Scope.**
- Context-aware trigger engine in Rust: watches activity signals (keyboard activity, active window, last-interaction timestamp) and fires `nudge` events when triggers match.
- Trigger set for v1: `inactivity_with_active_timer` (timer running, no UI interaction for 90 s), `pending_morning_triage` (past 10:00 and triage untouched), `micro_step_idle` (micro-step generated, not acted on within 15 min).
- Delivery: OS notification via `tauri-plugin-notification`. Platform sounds: `Glass` on macOS, `message-new-instant` on Linux, `Default` on Windows. Haptic cue on mobile (not yet in scope; desktop first).
- Suppression rules: no nudge if user typed in last 5 s, no nudge during a running timer's first 60 s, hard cap at 3 nudges per hour, instant mute button in settings.
- Rhythmic voice anchoring: piggy-back on Phase 4 TTS. Optional "speak nudges aloud" toggle. Default off. When on, short calm lines ("Time to move on", "Your list is still here"). No branding voice, no personality yet — that's a Phase 9 polish item.
**Out of scope.** Custom trigger editor (user-facing rule UI is Phase 7). Cross-device delivery. Biometric signals (HRV, fidget detection, etc.). Any Margot-as-character visual.
**Acceptance.** Nudges fire on each trigger in a dogfood walkthrough. Suppression rules observed. Mute button kills everything immediately.
**Estimated effort.** 1 2 days.
---
## Phase 7 — Implementation intentions (if-then automation)
**Why here.** Leans on Phase 6's trigger engine. The user-facing rule editor is its own thing, but the execution path is the nudge pipeline with user-defined conditions.
**Scope.**
- Rule editor UI: minimal. `if [when-condition], then [action]`.
- When-conditions for v1: `time of day = HH:MM`, `after a task completes`, `morning triage finishes`.
- Actions for v1: `surface task X`, `start a 5-min timer`, `speak a line aloud`.
- Rules stored in SQLite. On/off per rule. Global mute respected.
- No location triggers (desktop app, no geolocation). No app-running detection in v1 (fragile cross-platform; revisit in v0.2).
**Out of scope.** Calendar triggers, cross-app automation, macro-style action chains, shared/community rules.
**Acceptance.** User can write "at 09:00, speak 'time to plan the day' and surface inbox", save it, and have it fire next morning at 09:00. Delete works.
**Estimated effort.** 1 day.
---
## Phase 8 — Forgiving gamification
**Why here.** Last feature phase because it's the lowest-risk and the furthest from make-or-break. Neatly rounds out the spec list.
**Scope.**
- Completion count per day (non-punitive: no streaks, no chain-breaking). "You've completed 4 tasks today. Three in the afternoon. Want to call it?"
- Grace days: the badge logic ignores up-to-3 consecutive days of no activity without reset.
- Visual: soft-edged numeric badges on the tasks header, no leaderboards, no social comparison.
- Zero loss language. Never "you lost your streak". Framing is always "look what you did".
**Out of scope.** Leaderboards. Shared challenges. Streak repair purchases. XP systems.
**Acceptance.** Complete 3 tasks → header shows "3 today". Open the app after 4 days off → no negative framing, header reads today's count only.
**Estimated effort.** Half day to 1 day.
---
## Phase 9 — Polish debt
> **All Phase 9 work is paused until Phase 1 Phase 8 are closed.** Per Jake's rule: features first, polish second.
**Contents.**
- File-system `.md` save dialog (replace clipboard-only export). Rust `write_text_file` command; platform dialog via `tauri-plugin-dialog`.
- Bulk select + bulk export in History.
- LLM-powered content tags (`topic:*`, `intent:*`). Slot into the existing `kon-llm` stub.
- Settings UX overhaul: bundle high-traffic settings into a "Start here" group; hide advanced behind a disclosure.
- Visual polish pass on all Phase 1 Phase 8 surfaces: spacing, typography, motion curves, colour, dark-mode parity.
- Accessibility pass: keyboard navigation, screen reader labels, focus order, colour contrast audit against WCAG AA.
**Estimated effort.** 1 2 days.
---
## Phase 10 — QC + rename + release
**Prerequisite:** Phase 1 Phase 9 complete.
**QC block.**
- Full dogfood walkthrough: record a real brain-dump → clean transcript → task extraction → micro-step one task → run a focus timer → tag energy → complete → check evening shutdown.
- RB-08 macOS power-assertion verification: **Rachmann runs this offline** on his Mac. He runs `pmset -g assertions` while a live session is active; expected: entry for `PreventSystemSleep` attributed to Corbie's bundle id. Once confirmed, mark RB-08 closed and delete `docs/issues/power-assertion-macos-objc2.md` or move to resolved.
- Cross-platform build matrix (CI already runs): Linux / macOS / Windows, ensure all three are green.
- Accessibility regression check: keyboard-only traversal of each new surface.
- Freshly-clean install test on a spare user account: no stray data leaks from dev.
**Kon → Corbie codebase rename sweep.** Runs after QC once Jake has renamed the two repos:
- `package.json` name `kon@0.1.0``corbie@0.1.0`.
- Cargo crate names: `kon`, `kon-audio`, `kon-storage`, `kon-transcription`, `kon-llm`, `kon-ai-formatting`, `kon-core`, `kon-cloud-providers`, `kon-hotkey`, `kon-mcp``corbie-*`. Mass-rename via `Cargo.toml` + `use`-path sweep.
- Binary + product names: `src-tauri/tauri.conf.json`, bundle identifier, `.desktop` file, Windows product name, macOS bundle name.
- Install paths: `~/.local/share/kon/``~/.local/share/corbie/`. **Migration shim required**: first-run check for old dir, move contents, write a sentinel. Document in the release notes.
- Database filename: `kon.db``corbie.db`. Handled by the same migration shim.
- Window titles, tray tooltip, About-dialog text, README body, docs/brief/ references where they refer to the product (leave historical brief content talking about "Kon" as-is — it's a historical document).
- Event names: `kon:start-timer``corbie:start-timer` and similar. Kept `kon:` through Phase 1 Phase 9 so any dogfood doesn't need to re-learn them mid-cycle.
- Logs, error messages, user-facing copy.
- Remotes: `ssh://git.corbel.consulting:2222/jake/kon.git` + `github.com:jakejars/kon.git``…/corbie.git` on both, after Jake has clicked rename in the web UIs. Update `git remote set-url` locally.
**Release.**
- Bump `Cargo.toml` and `package.json` to `0.1.0`. Tag `v0.1.0` on the commit.
- Write `CHANGELOG.md` (seed it with everything from this roadmap's phases).
- Write release notes in plain language — what it does, who it's for, the data-migration note.
- Push tag to both remotes. GitHub Actions release workflow (already in place for cross-platform CI) should auto-build artefacts for Linux / macOS / Windows.
**Estimated effort.** 1 day (QC + rename + release ceremony), plus Rachmann's slot on his Mac (parallel, not blocking).
---
## Totals
- Phase 1 8 feature build: **6.5 9.5 days** of focused work
- Phase 9 polish: **1 2 days**
- Phase 10 QC + rename + release: **1 day + Rachmann's Mac session**
**Total to v0.1.0 feature-complete release:** **~9 13 days of focused work**, depending on how much polish time Jake wants in Phase 9.
## Explicit non-goals
- Mobile apps. Corbie is desktop-first; a mobile companion is post-v0.1.
- Cloud sync. Local-first is the floor, not a feature. Sync is out of scope through v0.1.
- Premium voices, paid tiers, subscription. Licensing + monetisation is a separate track tracked in memory `project_marketplace_creem`.
- AI body doubling (low-fi focus rooms) — validated but parked to post-v0.1.
- Temptation bundling — cut (OS-integration impossible cross-platform; replaced by Phase 3 energy-aware sequencing).
## Anchors
- Spec: [docs/brief/feature-set.md](docs/brief/feature-set.md) + [docs/brief/design-principles.md](docs/brief/design-principles.md)
- Current baseline: this session's HANDOVER.md
- Rebrand memory: `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_corbie_rebrand.md`
- Release-blocker index: [docs/issues/README.md](docs/issues/README.md)
---
*This roadmap is a living document. Update it at the end of each phase with actuals vs estimates and any scope revisions.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

90
package-lock.json generated
View File

@@ -18,8 +18,8 @@
"svelte-i18n": "^4.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
@@ -27,7 +27,7 @@
"svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3"
"vite": "^6.4.2"
}
},
"node_modules/@chenglou/pretext": {
@@ -958,9 +958,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"version": "2.58.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -987,7 +987,7 @@
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
@@ -1262,6 +1262,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
@@ -2375,9 +2439,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3098,9 +3162,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -23,8 +23,8 @@
"svelte-i18n": "^4.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
@@ -32,6 +32,6 @@
"svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3"
"vite": "^6.4.2"
}
}

View File

@@ -64,7 +64,7 @@ fn assert_localhost_llm_csp() {
let tokens: Vec<&str> = connect_src.split_whitespace().collect();
for required in ["http://127.0.0.1:*", "ws://127.0.0.1:*"] {
assert!(
tokens.iter().any(|t| *t == required),
tokens.contains(&required),
"build.rs: tauri.conf.json CSP connect-src must permit {required} \
for local LLM connectivity (brief item #2). Current connect-src: \
{connect_src:?}"

View File

@@ -247,7 +247,7 @@ pub async fn generate_diagnostic_report(
.map(|d| d.as_secs())
.unwrap_or(0);
out.push_str(&format!("- Generated: unix `{}`\n", now));
out.push_str("\n");
out.push('\n');
out.push_str(
"> This report is local-only until you choose to share it. \
Review the contents below before sending to anyone.\n\n",

View File

@@ -0,0 +1,110 @@
// Tauri commands for human-in-the-loop feedback capture and retrieval.
// Phase 2 of the feature-complete roadmap: thumbs + correction capture
// on AI-generated output feeds a few-shot loop that conditions future
// prompts on the user's preferred style.
use serde::{Deserialize, Serialize};
use kon_storage::{
list_feedback_examples as db_list_feedback_examples, record_feedback as db_record_feedback,
FeedbackRow, FeedbackTargetType, RecordFeedbackParams,
};
use crate::AppState;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordFeedbackInput {
/// One of "microstep", "task_extraction", "cleanup".
pub target_type: String,
/// Optional surface-specific id (subtask id, task id, transcript id).
#[serde(default)]
pub target_id: Option<String>,
/// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
pub rating: i8,
#[serde(default)]
pub original_text: Option<String>,
#[serde(default)]
pub corrected_text: Option<String>,
/// Freeform JSON context: e.g. the parent task text, the transcript
/// chunk the AI was given, etc. Used later by the prompt builder
/// to reconstruct the (input, preferred-output) pair.
#[serde(default)]
pub context_json: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FeedbackDto {
pub id: i64,
pub target_type: String,
pub target_id: Option<String>,
pub rating: i64,
pub original_text: Option<String>,
pub corrected_text: Option<String>,
pub context_json: Option<String>,
pub profile_id: String,
pub created_at: String,
}
impl From<FeedbackRow> for FeedbackDto {
fn from(r: FeedbackRow) -> Self {
Self {
id: r.id,
target_type: r.target_type,
target_id: r.target_id,
rating: r.rating,
original_text: r.original_text,
corrected_text: r.corrected_text,
context_json: r.context_json,
profile_id: r.profile_id,
created_at: r.created_at,
}
}
}
fn parse_target_type(raw: &str) -> Result<FeedbackTargetType, String> {
FeedbackTargetType::parse(raw).ok_or_else(|| format!("unknown feedback target_type: {raw}"))
}
#[tauri::command]
pub async fn record_feedback(
state: tauri::State<'_, AppState>,
input: RecordFeedbackInput,
) -> Result<i64, String> {
let target_type = parse_target_type(&input.target_type)?;
db_record_feedback(
&state.db,
RecordFeedbackParams {
target_type,
target_id: input.target_id,
rating: input.rating,
original_text: input.original_text,
corrected_text: input.corrected_text,
context_json: input.context_json,
profile_id: input.profile_id,
},
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_feedback_examples_cmd(
state: tauri::State<'_, AppState>,
target_type: String,
limit: Option<i64>,
min_rating: Option<i8>,
profile_id: Option<String>,
) -> Result<Vec<FeedbackDto>, String> {
let target = parse_target_type(&target_type)?;
let limit = limit.unwrap_or(8).clamp(1, 64);
let min_rating = min_rating.unwrap_or(0).clamp(-1, 1);
let rows =
db_list_feedback_examples(&state.db, target, limit, min_rating, profile_id.as_deref())
.await
.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(FeedbackDto::from).collect())
}

View File

@@ -1086,11 +1086,12 @@ fn build_nearby_transcript_candidates(
let mut texts: Vec<String> = Vec::new();
for start in 0..nearby.len() {
let mut merged = String::new();
for end in start..nearby.len().min(start + DUPLICATE_TRANSCRIPT_MERGE_LIMIT) {
let upper = nearby.len().min(start + DUPLICATE_TRANSCRIPT_MERGE_LIMIT);
for segment in &nearby[start..upper] {
if !merged.is_empty() {
merged.push(' ');
}
merged.push_str(nearby[end].text.trim());
merged.push_str(segment.text.trim());
if !texts.iter().any(|existing| existing == &merged) {
texts.push(merged.clone());
}
@@ -1155,12 +1156,10 @@ fn longest_common_token_subsequence(a: &[&str], b: &[&str]) -> usize {
}
fn is_low_signal_token(token: &str) -> bool {
LOW_SIGNAL_TOKENS
.iter()
.any(|low_signal| *low_signal == token)
LOW_SIGNAL_TOKENS.contains(&token)
}
fn meaningful_tokens<'a>(text: &'a str) -> Vec<&'a str> {
fn meaningful_tokens(text: &str) -> Vec<&str> {
text.split_whitespace()
.filter(|token| !token.is_empty() && token.len() > 1 && !is_low_signal_token(token))
.collect()

View File

@@ -1,6 +1,7 @@
pub mod audio;
pub mod clipboard;
pub mod diagnostics;
pub mod feedback;
pub mod hardware;
pub mod hotkey;
pub mod live;

View File

@@ -412,7 +412,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
reason: None,
};
}
return ActiveComputeDevice {
ActiveComputeDevice {
kind: "cpu".into(),
label: "CPU (fallback)".into(),
reason: Some(
@@ -420,7 +420,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
libvulkan1 (Linux) to enable GPU acceleration."
.into(),
),
};
}
}
}

View File

@@ -345,7 +345,7 @@ fn classify_terminal(raw: &str) -> Option<String> {
fn detect_focused_window_class() -> Option<String> {
#[cfg(target_os = "linux")]
{
return detect_focused_window_class_linux();
detect_focused_window_class_linux()
}
#[cfg(target_os = "macos")]
{

View File

@@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use kon_llm::prompts::FeedbackExample as LlmFeedbackExample;
use kon_storage::{
complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task,
delete_task as db_delete_task, get_task_by_id as db_get_task,
insert_subtask as db_insert_subtask, insert_task as db_insert_task,
list_subtasks as db_list_subtasks, list_tasks as db_list_tasks,
uncomplete_task as db_uncomplete_task, update_task as db_update_task, TaskRow,
list_feedback_examples as db_list_feedback_examples, list_subtasks as db_list_subtasks,
list_tasks as db_list_tasks, uncomplete_task as db_uncomplete_task,
update_task as db_update_task, FeedbackRow, FeedbackTargetType, TaskRow,
};
use crate::AppState;
@@ -166,6 +168,34 @@ pub async fn uncomplete_task_cmd(
.map_err(|e| e.to_string())
}
/// Convert HITL feedback rows fetched from storage into the few-shot
/// exemplar shape the LLM crate consumes. We reconstruct the `input`
/// (parent task text, transcript chunk) from `context_json` where the
/// recorder has stored it. Rows without usable input are dropped —
/// the prompt builder filters them too, but doing it here keeps the
/// exemplar list tight and the prompt budget predictable.
fn to_llm_examples(rows: Vec<FeedbackRow>) -> Vec<LlmFeedbackExample> {
rows.into_iter()
.filter_map(|r| {
let ctx: serde_json::Value =
serde_json::from_str(r.context_json.as_deref().unwrap_or("{}")).ok()?;
let input = ctx
.get("input")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_default();
if input.trim().is_empty() {
return None;
}
Some(LlmFeedbackExample {
input,
original_output: r.original_text,
corrected_output: r.corrected_text,
})
})
.collect()
}
#[tauri::command]
pub async fn decompose_and_store(
state: tauri::State<'_, AppState>,
@@ -176,12 +206,23 @@ pub async fn decompose_and_store(
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Task {parent_task_id} not found"))?;
// Pull recent micro-step feedback so the system prompt gets
// conditioned on the user's preferred decomposition style. We
// cap at 5 examples to keep the prompt under budget regardless
// of how much feedback has been captured.
let examples = db_list_feedback_examples(&state.db, FeedbackTargetType::MicroStep, 5, 0, None)
.await
.map(to_llm_examples)
.unwrap_or_default();
let engine = state.llm_engine.clone();
let parent_text = parent.text.clone();
let steps = tokio::task::spawn_blocking(move || engine.decompose_task(&parent_text))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let steps = tokio::task::spawn_blocking(move || {
engine.decompose_task_with_feedback(&parent_text, &examples)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut created = Vec::new();
for text in steps {
@@ -205,8 +246,14 @@ pub async fn extract_tasks_from_transcript_cmd(
state: tauri::State<'_, AppState>,
transcript: String,
) -> Result<Vec<String>, String> {
let examples =
db_list_feedback_examples(&state.db, FeedbackTargetType::TaskExtraction, 5, 0, None)
.await
.map(to_llm_examples)
.unwrap_or_default();
let engine = state.llm_engine.clone();
tokio::task::spawn_blocking(move || engine.extract_tasks(&transcript))
tokio::task::spawn_blocking(move || engine.extract_tasks_with_feedback(&transcript, &examples))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())

View File

@@ -234,7 +234,7 @@ pub fn run() {
// Runtime-warning banner: push CPU-feature + Vulkan-loader
// fallbacks to the frontend so Settings can render a one-line
// hint. No-ops on a fully-supported box.
crate::commands::models::emit_runtime_warnings(&app.handle());
crate::commands::models::emit_runtime_warnings(app.handle());
if let Err(e) = tray::setup(app) {
eprintln!("Failed to setup tray: {e}");
@@ -288,6 +288,9 @@ pub fn run() {
commands::tasks::extract_tasks_from_transcript_cmd,
commands::tasks::list_subtasks_cmd,
commands::tasks::complete_subtask_cmd,
// HITL feedback (Phase 2 roadmap)
commands::feedback::record_feedback,
commands::feedback::list_feedback_examples_cmd,
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
commands::profiles::list_profiles_cmd,
commands::profiles::get_profile_cmd,

View File

@@ -0,0 +1,298 @@
<script lang="ts">
// Floating focus-timer overlay. Renders nothing when no timer is
// active. When a timer is running, pins a compact SVG progress ring
// to the top-right of the viewport with the remaining mm:ss in the
// centre. Completion plays a gentle chime, flashes a success ring
// for 3 s, then disappears. Cancel button appears on hover.
//
// Mounted once in +layout.svelte. Listens for `kon:start-timer`
// events from anywhere in the app (e.g. MicroSteps) and delegates
// to the focus-timer store.
//
// Design tokens used: --color-accent (mid-progress),
// --color-warning (final 15%), --color-success (flourish),
// --color-border (unfilled ring track). No literals, so the ring
// follows the sensory-zone theme switcher in Settings.
import { onMount, onDestroy } from "svelte";
import { X, Plus, ExternalLink } from "lucide-svelte";
import { focusTimer } from "$lib/stores/focusTimer.svelte.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
// Hide the "pop out" button inside the float window itself — opening
// a second float from a float would be silly and would re-mount the
// same component. Detect via URL rather than a prop so we do not
// have to thread context through every mount site.
let isSecondaryWindow = $state(false);
if (typeof window !== "undefined") {
isSecondaryWindow = window.location.pathname.startsWith("/float")
|| window.location.pathname.startsWith("/viewer");
}
function handlePopOut() {
// Mirror the button in TasksPage.svelte — opens the always-on-top
// Now list + pinned timer in one floating window.
if (!hasTauriRuntime()) return;
window.open("/float", "_blank", "width=380,height=520");
}
const RING_SIZE = 64;
const RING_STROKE = 5;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
// Progress ring fills as time elapses. Stroke-dashoffset goes from
// circumference (empty) to 0 (full) — we want it the other way,
// because the UX is a shrinking-time disc: more elapsed = less
// ring visible. Render the remaining arc: dashoffset = circumference * progress.
let dashOffset = $derived(RING_CIRCUMFERENCE * focusTimer.progress);
function formatRemaining(ms: number): string {
const total = Math.ceil(ms / 1000);
const mins = Math.floor(total / 60);
const secs = total % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
// Colour shifts over the last 15% of the timer to cue "nearly done"
// without resorting to red (which the brief flags as anxiogenic for
// the target audience).
let ringColor = $derived.by(() => {
if (focusTimer.showingCompletionFlash) return "var(--color-success)";
if (focusTimer.progress >= 0.85) return "var(--color-warning)";
return "var(--color-accent)";
});
// Event handler: start a timer when any component fires `kon:start-timer`.
// Payload shape from MicroSteps.svelte and task row buttons:
// { taskId?: string, seconds: number, label?: string }
function handleStartEvent(evt: Event) {
const detail = (evt as CustomEvent).detail ?? {};
const seconds = Number(detail.seconds);
if (!Number.isFinite(seconds) || seconds <= 0) return;
focusTimer.start(seconds, {
taskId: detail.taskId ?? null,
label: detail.label ?? null,
});
}
onMount(() => {
window.addEventListener("kon:start-timer", handleStartEvent);
// Rehydrate any in-flight timer that survived a window close.
focusTimer.rehydrate();
});
onDestroy(() => {
window.removeEventListener("kon:start-timer", handleStartEvent);
});
function handleCancel() {
focusTimer.cancel();
}
function handleExtend() {
focusTimer.extend(60);
}
function handleDismissFlash() {
focusTimer.dismissCompletionFlash();
}
</script>
{#if focusTimer.active || focusTimer.showingCompletionFlash}
<div
class="focus-timer"
class:completed={focusTimer.showingCompletionFlash}
role="status"
aria-live="polite"
aria-label={focusTimer.label ?? "Focus timer"}
>
<div class="ring-wrap">
<svg
width={RING_SIZE}
height={RING_SIZE}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
aria-hidden="true"
>
<!-- Track -->
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
stroke="var(--color-border)"
stroke-width={RING_STROKE}
/>
<!-- Progress (shrinking slice — full ring at start, none at end) -->
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
stroke={ringColor}
stroke-width={RING_STROKE}
stroke-linecap="round"
stroke-dasharray={RING_CIRCUMFERENCE}
stroke-dashoffset={dashOffset}
transform={`rotate(-90 ${RING_SIZE / 2} ${RING_SIZE / 2})`}
style="transition: stroke-dashoffset 250ms linear, stroke 400ms ease"
/>
</svg>
<div class="time" aria-hidden={focusTimer.showingCompletionFlash}>
{#if focusTimer.showingCompletionFlash}
<span class="done">done</span>
{:else}
{formatRemaining(focusTimer.remainingMs)}
{/if}
</div>
</div>
<div class="controls">
{#if focusTimer.showingCompletionFlash}
<button
class="icon-btn"
onclick={handleDismissFlash}
aria-label="Dismiss completion"
title="Dismiss"
>
<X size={14} aria-hidden="true" />
</button>
{:else}
<button
class="icon-btn"
onclick={handleExtend}
aria-label="Add one minute"
title="+1 min"
>
<Plus size={14} aria-hidden="true" />
</button>
{#if !isSecondaryWindow}
<button
class="icon-btn"
onclick={handlePopOut}
aria-label="Pop out timer + Now list into floating window"
title="Pop out (keeps timer + tasks on top)"
>
<ExternalLink size={14} aria-hidden="true" />
</button>
{/if}
<button
class="icon-btn"
onclick={handleCancel}
aria-label="Cancel timer"
title="Cancel"
>
<X size={14} aria-hidden="true" />
</button>
{/if}
</div>
{#if focusTimer.label}
<div class="label" aria-hidden="true">{focusTimer.label}</div>
{/if}
</div>
{/if}
<style>
.focus-timer {
position: fixed;
top: 52px;
right: 16px;
z-index: 40;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 6px 6px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-family: var(--font-family-body);
font-variant-numeric: tabular-nums;
transition: opacity 200ms ease, transform 200ms ease;
}
.focus-timer.completed {
border-color: var(--color-success);
}
.ring-wrap {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.time {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
}
.time .done {
font-size: 11px;
color: var(--color-success);
font-family: var(--font-family-display);
letter-spacing: 0.02em;
}
.controls {
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 180ms ease;
}
.focus-timer:hover .controls,
.focus-timer:focus-within .controls,
.focus-timer.completed .controls {
opacity: 1;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.icon-btn:hover {
background: var(--color-hover);
color: var(--color-text);
}
.icon-btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.label {
max-width: 140px;
font-size: 12px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global([data-reduce-motion="true"]) .focus-timer circle {
transition: none !important;
}
</style>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
let { parentTaskId, reduceMotion = false } = $props();
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
interface Subtask {
id: string;
@@ -15,6 +15,15 @@
let error = $state('');
let decomposing = $state(false);
// Per-step UI state. Keyed by subtask id so we never lose state when
// the list reorders. Values:
// rating[id] — 1 | -1 — the thumbs vote the user gave this session
// editing[id] — true while the user is editing the step text
// draft[id] — the in-flight edit value before save
let rating = $state<Record<string, 1 | -1 | undefined>>({});
let editing = $state<Record<string, boolean>>({});
let draft = $state<Record<string, string>>({});
async function loadSubtasks() {
loading = true;
error = '';
@@ -53,6 +62,88 @@
}));
}
// --- HITL feedback --------------------------------------------------------
//
// All three paths (thumbs up, thumbs down, correction-via-edit) route
// into the same `record_feedback` command. The parent task text is the
// "input" the AI was given, so it travels in context_json so the prompt
// builder can reconstruct the (input, good-output) pair.
function feedbackContextJson() {
return JSON.stringify({ input: parentTaskText ?? '' });
}
async function recordThumb(step: Subtask, ratingValue: 1 | -1) {
// Toggle: if the user already voted the same way, clear it (record
// rating 0 means correction, not a thumb-off — we just skip the
// re-record and drop the local highlight). Unvoting isn't stored;
// the audit trail stays immutable.
if (rating[step.id] === ratingValue) {
const next = { ...rating };
delete next[step.id];
rating = next;
return;
}
rating = { ...rating, [step.id]: ratingValue };
try {
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: ratingValue,
originalText: step.text,
correctedText: null,
contextJson: feedbackContextJson(),
},
});
} catch (_) { /* feedback capture is best-effort, never fatal */ }
}
function startEdit(step: Subtask) {
editing = { ...editing, [step.id]: true };
draft = { ...draft, [step.id]: step.text };
}
function cancelEdit(stepId: string) {
const nextE = { ...editing }; delete nextE[stepId]; editing = nextE;
const nextD = { ...draft }; delete nextD[stepId]; draft = nextD;
}
async function saveEdit(step: Subtask) {
const next = (draft[step.id] ?? '').trim();
cancelEdit(step.id);
if (!next || next === step.text) return;
const original = step.text;
// Update in-memory first so the UI is snappy; roll back if the
// persistence call fails so we never show stale-but-different text.
const idx = subtasks.findIndex(s => s.id === step.id);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], text: next };
try {
await invoke('update_task_cmd', {
id: step.id,
patch: { text: next },
});
// Record correction as the highest-value feedback signal.
await invoke('record_feedback', {
input: {
targetType: 'microstep',
targetId: step.id,
rating: 0,
originalText: original,
correctedText: next,
contextJson: feedbackContextJson(),
},
}).catch(() => {});
} catch (_) {
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], text: original };
}
}
function handleEditKeydown(evt: KeyboardEvent, step: Subtask) {
if (evt.key === 'Enter') { evt.preventDefault(); saveEdit(step); }
else if (evt.key === 'Escape') { evt.preventDefault(); cancelEdit(step.id); }
}
$effect(() => {
if (parentTaskId) loadSubtasks();
});
@@ -97,10 +188,64 @@
<Check size={9} aria-hidden="true" />
{/if}
</button>
<span class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate">
{step.text}
</span>
{#if !step.done}
{#if editing[step.id]}
<!-- svelte-ignore a11y_autofocus — deliberate: inline edit
is user-initiated and focus must land on the input to
match the UX pattern users expect from any task app. -->
<input
type="text"
bind:value={draft[step.id]}
onkeydown={(e) => handleEditKeydown(e, step)}
onblur={() => saveEdit(step)}
class="text-[12px] flex-1 min-w-0 bg-bg-input border border-accent rounded px-1.5 py-0.5 text-text focus:outline-none"
autofocus
data-no-transition
/>
{:else}
<button
type="button"
class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate text-left cursor-text bg-transparent border-0 p-0"
ondblclick={() => !step.done && startEdit(step)}
disabled={step.done}
aria-label="Double-click to edit this step"
title="Double-click to edit"
>{step.text}</button>
{/if}
{#if !step.done && !editing[step.id]}
<!-- HITL feedback: thumbs vote + pencil edit. All three
route into record_feedback and feed the prompt-conditioning
loop. See docs/roadmap/2026-04-23-... Phase 2. -->
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-success
{rating[step.id] === 1 ? '!opacity-100 text-success' : ''}"
onclick={() => recordThumb(step, 1)}
aria-label={rating[step.id] === 1 ? 'Remove thumbs up' : 'Thumbs up — this is a good step'}
title="Thumbs up — train the model on this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsUp size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-danger
{rating[step.id] === -1 ? '!opacity-100 text-danger' : ''}"
onclick={() => recordThumb(step, -1)}
aria-label={rating[step.id] === -1 ? 'Remove thumbs down' : 'Thumbs down — this misses the mark'}
title="Thumbs down — avoid this style"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui), color var(--duration-ui)'}
>
<ThumbsDown size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 p-0.5 text-text-tertiary hover:text-accent"
onclick={() => startEdit(step)}
aria-label="Edit this step (the correction trains future suggestions)"
title="Edit — this is the strongest training signal"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<Pencil size={10} aria-hidden="true" />
</button>
<button
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
onclick={() => startTimer(step.id)}

View File

@@ -1,7 +1,13 @@
<script lang="ts">
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
import { ChevronDown, ChevronRight, Timer } from 'lucide-svelte';
function startFocusTimer(task: { id: string; text: string }) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: task.id, seconds: 300, label: task.text }
}));
}
let { wipLimit = 3 } = $props();
@@ -68,6 +74,16 @@
aria-label="Complete task"
></button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<!-- 5-min focus timer — the "just-start" button from the brief -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
onclick={() => startFocusTimer(task)}
aria-label="Start 5-minute focus timer for this task"
title="Start 5-minute focus timer"
style="transition: opacity var(--duration-ui)"
>
<Timer size={12} aria-hidden="true" />
</button>
<!-- Expand/collapse micro-steps toggle -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
@@ -91,7 +107,7 @@
</div>
<!-- Micro-steps panel (expanded) -->
{#if expandedTaskIds.has(task.id)}
<MicroSteps parentTaskId={task.id} />
<MicroSteps parentTaskId={task.id} parentTaskText={task.text} />
{/if}
</div>
{/each}

View File

@@ -0,0 +1,230 @@
// Focus timer store. Single active timer at a time — the "just-start"
// 2/5/10/15-minute countdown paired with micro-steps. Exposes:
// - focusTimer.active: whether a timer is currently running
// - focusTimer.progress: 0..1 fraction of elapsed time
// - focusTimer.remainingMs: milliseconds until completion
// - focusTimer.label / focusTimer.taskId: what this timer is for
// - start(seconds, opts) / cancel() / extend(seconds)
//
// Survives window close + reopen via localStorage, because a timer
// that loses its clock when the user alt-tabs is a timer that nobody
// trusts. On rehydrate after expiry, fires completion then clears —
// so closing the window mid-timer still gets you the "done" signal
// on next launch.
const STORAGE_KEY = "kon.focusTimer.v1";
const TICK_INTERVAL_MS = 250;
export type FocusTimerPersisted = {
startedAt: number;
durationMs: number;
taskId: string | null;
label: string | null;
};
function readPersisted(): FocusTimerPersisted | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed?.startedAt !== "number" ||
typeof parsed?.durationMs !== "number"
) return null;
return {
startedAt: parsed.startedAt,
durationMs: parsed.durationMs,
taskId: parsed.taskId ?? null,
label: parsed.label ?? null,
};
} catch {
return null;
}
}
function writePersisted(state: FocusTimerPersisted | null): void {
if (typeof window === "undefined") return;
try {
if (state === null) window.localStorage.removeItem(STORAGE_KEY);
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* storage may be disabled; non-fatal */ }
}
function createFocusTimerStore() {
let startedAt = $state<number | null>(null);
let durationMs = $state<number>(0);
let taskId = $state<string | null>(null);
let label = $state<string | null>(null);
let now = $state<number>(Date.now());
let completionFlashUntil = $state<number>(0);
let interval: ReturnType<typeof setInterval> | null = null;
const active = $derived(startedAt !== null);
const elapsedMs = $derived(startedAt === null ? 0 : Math.max(0, now - startedAt));
const remainingMs = $derived(Math.max(0, durationMs - elapsedMs));
const progress = $derived(durationMs === 0 ? 0 : Math.min(1, elapsedMs / durationMs));
const finished = $derived(active && remainingMs === 0);
const showingCompletionFlash = $derived(now < completionFlashUntil);
// Internal completion + flash bookkeeping. We track whether we have
// already fired the completion chime for the current timer so a
// second tick does not re-fire it. Reset whenever a new timer starts.
let completionFired = false;
function tick() {
now = Date.now();
// Fire completion once, the first tick after we cross remaining=0.
if (startedAt !== null && !completionFired && now - startedAt >= durationMs) {
completionFired = true;
completionFlashUntil = now + 3000;
fireCompletion();
}
// After the 3 s flash window, clear everything and stop ticking.
if (completionFlashUntil > 0 && now >= completionFlashUntil) {
clear();
}
}
function startTick() {
if (interval !== null) return;
interval = setInterval(tick, TICK_INTERVAL_MS);
}
function stopTick() {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
}
function clear() {
startedAt = null;
durationMs = 0;
taskId = null;
label = null;
completionFlashUntil = 0;
completionFired = false;
writePersisted(null);
stopTick();
}
function start(seconds: number, opts?: { taskId?: string | null; label?: string | null }): void {
if (!Number.isFinite(seconds) || seconds <= 0) return;
now = Date.now();
startedAt = now;
durationMs = Math.floor(seconds * 1000);
taskId = opts?.taskId ?? null;
label = opts?.label ?? null;
completionFlashUntil = 0;
completionFired = false;
writePersisted({ startedAt, durationMs, taskId, label });
startTick();
}
function cancel(): void {
clear();
}
function extend(seconds: number): void {
if (startedAt === null) return;
if (!Number.isFinite(seconds) || seconds <= 0) return;
durationMs += Math.floor(seconds * 1000);
writePersisted({ startedAt, durationMs, taskId, label });
}
function dismissCompletionFlash(): void {
completionFlashUntil = 0;
clear();
}
function fireCompletion(): void {
if (typeof window === "undefined") return;
// Gentle chime — WebAudio so we do not ship a bundled asset.
// A 440 Hz fall into 330 Hz over 220 ms, low volume, no sustain.
try {
type AudioCtx = typeof AudioContext;
const win = window as unknown as { AudioContext?: AudioCtx; webkitAudioContext?: AudioCtx };
const Ctx = win.AudioContext ?? win.webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(440, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(330, ctx.currentTime + 0.22);
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.28);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.3);
osc.onended = () => ctx.close().catch(() => {});
} catch { /* audio is a nicety; never fatal */ }
window.dispatchEvent(new CustomEvent("kon:focus-timer-complete", {
detail: { taskId, label },
}));
}
// Rehydrate on first touch. If the persisted timer has already
// expired, fire completion then clear so the user still gets the
// "done" signal they missed while the window was closed.
function rehydrate(): void {
const persisted = readPersisted();
if (!persisted) return;
const age = Date.now() - persisted.startedAt;
if (age >= persisted.durationMs) {
// Already expired while the window was closed. Fire a completion
// event so downstream listeners (nudges, UI flourishes) can react.
taskId = persisted.taskId;
label = persisted.label;
durationMs = persisted.durationMs;
startedAt = persisted.startedAt;
now = persisted.startedAt + persisted.durationMs;
// Flash briefly so the user knows it happened.
completionFlashUntil = Date.now() + 3000;
completionFired = true;
fireCompletion();
startTick();
return;
}
startedAt = persisted.startedAt;
durationMs = persisted.durationMs;
taskId = persisted.taskId;
label = persisted.label;
now = Date.now();
startTick();
}
// Exposed as frozen object. Getters so derivations stay reactive.
return {
get active() { return active; },
get progress() { return progress; },
get elapsedMs() { return elapsedMs; },
get remainingMs() { return remainingMs; },
get durationMs() { return durationMs; },
get taskId() { return taskId; },
get label() { return label; },
get showingCompletionFlash() { return showingCompletionFlash; },
start,
cancel,
extend,
rehydrate,
dismissCompletionFlash,
};
}
export const focusTimer = createFocusTimerStore();
// Preset durations surfaced in the UI. 2 / 5 / 10 / 15 minutes match
// the brief's guidance for the "just-start" timer and cover common
// Pomodoro / shorter-focus preferences.
export const FOCUS_TIMER_PRESETS_SECONDS: ReadonlyArray<{ label: string; seconds: number }> = [
{ label: "2 min", seconds: 120 },
{ label: "5 min", seconds: 300 },
{ label: "10 min", seconds: 600 },
{ label: "15 min", seconds: 900 },
];

View File

@@ -8,6 +8,7 @@
import Titlebar from "$lib/components/Titlebar.svelte";
import ToastViewport from "$lib/components/ToastViewport.svelte";
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
import FocusTimer from "$lib/components/FocusTimer.svelte";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
@@ -396,6 +397,13 @@
in the bottom-right of the viewport. (Day 3 of the upgrade plan) -->
<ToastViewport />
<!-- Global focus-timer overlay. Renders nothing until a `kon:start-timer`
event fires; then pins a shrinking colour ring to the top-right.
Phase 1 of the 2026-04-23 feature-complete roadmap — closes the
visual-time-representation gap from docs/brief/feature-set.md and
wires the dangling emit in MicroSteps.svelte. -->
<FocusTimer />
<!-- Invisible resize margins for frameless (macOS/Windows). On Linux we
use native decorations, so ResizeHandles would compete with the
compositor's own resize and is suppressed. -->

View File

@@ -12,6 +12,7 @@
PREFERENCES_CHANGED_EVENT,
} from "$lib/stores/preferences.svelte.js";
import Titlebar from "$lib/components/Titlebar.svelte";
import FocusTimer from "$lib/components/FocusTimer.svelte";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
let { children } = $props();
@@ -90,3 +91,9 @@
{@render children()}
</div>
</div>
<!-- Focus timer also visible in the always-on-top float window so a
running countdown stays with the Now list. The component is a
global overlay (position: fixed) so it pins to the top-right of
this window independent of the Tasks content below. -->
<FocusTimer />