Compare commits
87 Commits
pre-consol
...
749403697a
| Author | SHA1 | Date | |
|---|---|---|---|
| 749403697a | |||
| eb3d2f90ab | |||
| 6469663c25 | |||
| 22d554e85b | |||
| fa734c869f | |||
| d089fdb37f | |||
| 2da0a5bab8 | |||
|
|
be5a7146ca | ||
| 150059e174 | |||
|
|
17f4dff791 | ||
|
|
4abc2356c2 | ||
|
|
bd16c118cc | ||
|
|
73f8c45f86 | ||
|
|
d5d751c9ad | ||
|
|
a7fdc96e7b | ||
|
|
581a098508 | ||
|
|
c04c719d48 | ||
|
|
38da407942 | ||
|
|
fd48f55edb | ||
|
|
41be27b410 | ||
|
|
ab3bb9370c | ||
|
|
3d568148b8 | ||
|
|
f3fd86185e | ||
|
|
90f4d9b0fb | ||
|
|
dfa6457f1f | ||
| cc77befda8 | |||
| 0ca4e0ef9e | |||
| dd45f10cd4 | |||
| 3bc34d2873 | |||
| 6269aab0d2 | |||
| 7fc971df05 | |||
| 7eb52d97b1 | |||
| 489c066a70 | |||
| ef42c95000 | |||
| 7567bede52 | |||
| 1b6ad88ead | |||
| c26d82c26a | |||
| eb6e291191 | |||
| d1500cda8c | |||
| bfec88ccc9 | |||
| 5a15c931d0 | |||
| 3eb24f2c63 | |||
| 48d3db7395 | |||
| 49a795f533 | |||
| 512c9f7a19 | |||
| d8011bbe7c | |||
| fa93033165 | |||
| c29720e145 | |||
| 3cadbb0f82 | |||
| 54ddd41265 | |||
| 4ffdae9838 | |||
| cb32285ce0 | |||
| 42b423e4f4 | |||
| 83bd338aff | |||
| 839754f4ee | |||
| b992967e50 | |||
| 92b32282d9 | |||
| 729b82cf50 | |||
| d5eb212246 | |||
| 2cc0697de9 | |||
| 6cd1c22c0f | |||
| eebea8cb9a | |||
| b333c6229e | |||
| 55b34d8ffc | |||
| 3cf3e41899 | |||
| 9f53702c7e | |||
| b344e8a580 | |||
| 1d4f1070a2 | |||
| a327f4d882 | |||
| d307722c7a | |||
| 46be0a5aca | |||
| f25f8db818 | |||
| bbc7c217be | |||
| 0c34a29367 | |||
| df6b19834d | |||
| 420da679f9 | |||
| 2b82b9be5b | |||
| 0e18a78fae | |||
| 4700668df1 | |||
| 4e947dec21 | |||
| 509b983c09 | |||
| 0b1c492edd | |||
| 6579c5fb6a | |||
| fe61661305 | |||
|
|
f8c9769e04 | ||
|
|
becbf69c35 | ||
|
|
b8b953dfa8 |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -169,3 +169,13 @@ jobs:
|
||||
path: ${{ matrix.artifact_glob }}
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Report artifact sizes
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d src-tauri/target/release/bundle ]; then
|
||||
find src-tauri/target/release/bundle -type f \
|
||||
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.msi' -o -name '*.exe' -o -name '*.dmg' -o -name '*.app' \) \
|
||||
-exec du -h {} + | sort -h
|
||||
fi
|
||||
|
||||
17
.github/workflows/check.yml
vendored
17
.github/workflows/check.yml
vendored
@@ -111,6 +111,8 @@ jobs:
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
# Cache the Cargo target dir + registry per OS so the heavy
|
||||
# whisper-rs-sys C++ build only happens on a clean cache.
|
||||
@@ -128,12 +130,24 @@ jobs:
|
||||
- name: cargo check (workspace)
|
||||
run: cargo check --workspace --all-targets
|
||||
|
||||
- name: cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Library tests only — no runtime/GPU deps. Linux-gated to keep
|
||||
# the macOS + Windows legs focused on compile coverage.
|
||||
- name: cargo test (workspace, libs)
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
- name: cargo audit
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
frontend:
|
||||
name: svelte build + lint
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -150,6 +164,9 @@ jobs:
|
||||
- name: Install JS deps
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
# `tauri build` inside check.yml would trigger the full Rust build
|
||||
# which is owned by the rust job. Here we only validate that the
|
||||
# Svelte/Vite frontend compiles cleanly.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@ target/
|
||||
build/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
Cargo.lock
|
||||
.firecrawl/
|
||||
.worktrees/
|
||||
.cargo/
|
||||
|
||||
7895
Cargo.lock
generated
Normal file
7895
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,10 @@
|
||||
[workspace]
|
||||
members = ["src-tauri", "crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
97
HANDOVER-2026-04-19.md
Normal file
97
HANDOVER-2026-04-19.md
Normal 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.
|
||||
```
|
||||
122
HANDOVER-2026-04-24.md
Normal file
122
HANDOVER-2026-04-24.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: handover-2026-04-24
|
||||
type: reference
|
||||
tags: [handover, session, kon, phase-8, gamification]
|
||||
description: Session handover — 2026/04/24 Phase 8 forgiving gamification shipped end-to-end
|
||||
---
|
||||
|
||||
# Corbie Handover — 2026/04/24
|
||||
|
||||
Phase 8 session. Executed the forgiving-gamification spec + plan written at the top of the session against `main`. Shipped 14 commits end-to-end. All automated gates clean; manual dogfood walkthrough still owed when Jake next opens the running app.
|
||||
|
||||
## Rebrand note
|
||||
|
||||
Product rename **Kon → Corbie** still in flight. Copy in new docs is "Corbie"; codebase paths / package names / repos still carry `kon`. No rebrand work this session. See `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_corbie_rebrand.md`.
|
||||
|
||||
## What shipped this session
|
||||
|
||||
### Phase 8 — forgiving gamification
|
||||
|
||||
Today's header now shows `Tasks · 3 today` alongside a 7-day momentum sparkline. No streaks, no grace days, no loss language. Commits on `main`, `729b82c` onwards:
|
||||
|
||||
| SHA | Summary |
|
||||
|---|---|
|
||||
| `2cc0697` | docs: design spec for Phase 8 |
|
||||
| `d5eb212` | docs: implementation plan for Phase 8 |
|
||||
| `729b82c` | migration v13, `auto_completed` column |
|
||||
| `92b3228` | cascade sets `auto_completed = 1` on parent |
|
||||
| `b992967` | style fix, drop em-dash from cascade comment |
|
||||
| `839754f` | `uncomplete_task` clears `auto_completed` |
|
||||
| `83bd338` | `list_recent_completions` storage fn + `DailyCompletionCount` + 5 tests |
|
||||
| `42b423e` | `list_recent_completions_cmd` Tauri wrapper |
|
||||
| `cb32285` | `DailyCompletionCount` type + `showMomentumSparkline` setting |
|
||||
| `4ffdae9` | `completionStats.svelte.ts` store |
|
||||
| `54ddd41` | `CompletionSparkline.svelte` component |
|
||||
| `3cadbb0` | badge + sparkline wired into Tasks header (+ `$derived` → getter fix) |
|
||||
| `c29720e` | emit `kon:task-uncompleted` + `kon:task-deleted` events |
|
||||
| `fa93033` | settings toggle for momentum sparkline |
|
||||
|
||||
### Counting semantics (locked)
|
||||
|
||||
- Manual top-level completions count.
|
||||
- Manual subtask completions count.
|
||||
- Cascade-completed parents (`auto_completed = 1`) do **not** count.
|
||||
- Uncompletions remove from the count on the spot.
|
||||
- Day boundaries are local time via `DATE(done_at, 'localtime')`.
|
||||
|
||||
### Architectural notes worth carrying forward
|
||||
|
||||
- **`serde` is now a dependency of `kon-storage`.** Added because `DailyCompletionCount` is serialised directly to the frontend via Tauri. The existing `TaskRow` → `TaskDto` split wasn't reused because the struct has no camelCase translation need (`day`, `count` are already frontend-friendly). Simpler, one fewer file to maintain.
|
||||
- **`$derived` cannot be exported at module scope in `.svelte.ts`.** Svelte 5 errors with `derived_invalid_export`. Originally hit during Task 9 integration; fix landed in the same commit (`3cadbb0`). `svelte-check` misses this; only Vite catches it. Plan/spec both mistakenly prescribed `$derived`; future stores should use `export function fooCount(): number` + `(...)` call sites, or a `$derived` wrapped inside a component script.
|
||||
- **Tuple `FromRow` in storage.** `kon-storage` strips sqlx's `derive` feature, so `#[derive(sqlx::FromRow)]` is not available. Use tuple `FromRow` `(String, i64)` etc. instead. Noted for future tasks in this crate.
|
||||
|
||||
## Verification state at session end
|
||||
|
||||
Fresh run on `main` tip `fa93033`:
|
||||
|
||||
- `cargo fmt --check`: clean.
|
||||
- `cargo clippy --all-targets -- -D warnings`: clean.
|
||||
- `cargo test`: **273 tests pass**, 0 failed, 0 ignored. Storage crate alone: 55 passed (6 new Phase 8 tests: column exists + default 0, cascade flag, uncomplete clear, 5-day series shape, cascade excluded, manual top-level counted, uncomplete excluded, local-day boundary).
|
||||
- `npm run check`: 0 errors, 0 warnings across 3955 files.
|
||||
- `npm run build`: clean production build via `@sveltejs/adapter-static`.
|
||||
|
||||
## Owed to Jake (next session)
|
||||
|
||||
1. **Manual dogfood walkthrough.** Cannot be driven by an automated agent. When opening Corbie next:
|
||||
- Fresh state, no completions → header shows only "Tasks" title; no badge, no sparkline.
|
||||
- Complete one top-level task → badge "1 today"; sparkline appears.
|
||||
- Complete two more → badge "3 today".
|
||||
- Uncomplete one → badge "2 today".
|
||||
- Micro-step a task; complete its final subtask so the cascade closes the parent → badge increments by 1 (subtask), not 2.
|
||||
- Settings → Rituals → toggle sparkline off → sparkline disappears, badge remains.
|
||||
- Toggle on → sparkline returns.
|
||||
|
||||
2. **Phase 9 polish backlog items surfaced during review:**
|
||||
- Sparkline `aria-label` currently reads numeric list ("0, 1, 3, 2, 0, 4, 3"). Friendlier summary form ("3 completed today, 14 total over 7 days") would reduce screen-reader tedium. Not changed because spec prescribed the numeric list verbatim.
|
||||
- Per-day tooltip on sparkline hover was explicitly deferred to Phase 9 by the spec.
|
||||
- Motion curves / enter animations on badge + sparkline deferred to Phase 9.
|
||||
- Settings toggle currently co-located under "Rituals" section. Code reviewer flagged that placement reads as part of the "Launch at login" subgroup (the `border-t` above is visually claimed by a different setting). Two options for Phase 9 polish: wrap the sparkline toggle in its own `mt-4 pt-4 border-t border-border-subtle` subgroup, or move it to its own "Tasks" / "Progress" section. Rituals copy ("All off by default. Rituals only appear when you ask for them.") is mildly broken by the default-on sparkline; relocate the toggle rather than soften the copy.
|
||||
|
||||
3. **Plan quality note for future Phase 9+ plans.** Two patterns I prescribed turned out to be wrong on this codebase and only surfaced during execution:
|
||||
- `$derived` at `.svelte.ts` module scope: not supported.
|
||||
- `#[derive(sqlx::FromRow)]` in `kon-storage`: feature is stripped.
|
||||
|
||||
Worth a one-screen "kon-storage gotchas" reference file or at least a note at the top of future plans that touch these areas.
|
||||
|
||||
## What's left for v0.1
|
||||
|
||||
Unchanged except for Phase 8 now being closed:
|
||||
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| Phases 1 to 8 | **All shipped.** |
|
||||
| Phase 9 | Polish debt (file-system .md save dialog, bulk select/export in History, LLM content tags, settings UX pass, visual polish, accessibility sweep). Absorbs backlog above. 1 to 2 days. |
|
||||
| Phase 10a | QC: dogfood walkthrough, Rachmann's RB-08 Mac verification (parallel), cross-platform CI, a11y regression, clean-install test. Half day. |
|
||||
| Phase 10b | Kon → Corbie rename sweep: package name, all 10 crates, bundle ids, install paths, `kon.db` → `corbie.db`, event names, repo rename on both remotes. Half to 1 day. |
|
||||
| Phase 10c | Release: 0.1.0 version sync, CHANGELOG seeded from roadmap phases, release notes, tag + push. Half day. |
|
||||
|
||||
### Release-blocker state
|
||||
|
||||
- **0 open CRITICAL.**
|
||||
- **1 open MAJOR.** RB-08 `power-assertion-macos-objc2` (awaits Rachmann's manual runtime verification on his Mac: `pmset -g assertions` during a live session). Gates v0.1 tagging.
|
||||
|
||||
### Cargo.lock
|
||||
|
||||
- `Cargo.lock` is committed as of `b333c62` (Jake's hardening pass). Roadmap doc updated this session to reflect resolution.
|
||||
|
||||
## Repo state at session end
|
||||
|
||||
- `main` at `fa93033`.
|
||||
- 14 Phase 8 commits + 2 doc commits on top of yesterday's tip.
|
||||
- Local branches: `main` only.
|
||||
- `cargo build --workspace` green / `cargo test --workspace` green (273 passing) / `cargo clippy --workspace --all-targets -- -D warnings` 0 warnings / `cargo fmt --check` clean / `npm run check` 0/0 / `npm run build` clean.
|
||||
|
||||
## Anchors
|
||||
|
||||
- Spec: [docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md](docs/superpowers/specs/2026-04-24-phase8-forgiving-gamification-design.md)
|
||||
- Plan: [docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md](docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md)
|
||||
- Roadmap: [docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md](docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md)
|
||||
- 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
|
||||
163
HANDOVER.md
163
HANDOVER.md
@@ -1,97 +1,116 @@
|
||||
---
|
||||
name: handover-2026-04-19
|
||||
name: handover-2026-04-25
|
||||
type: reference
|
||||
tags: [handover, session, kon]
|
||||
description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome
|
||||
tags: [handover, session, kon, phase-9, polish-debt]
|
||||
description: Session handover — 2026/04/24-25 Phase 9 polish debt mostly shipped
|
||||
---
|
||||
|
||||
# Kon Handover — 2026/04/19
|
||||
# Corbie Handover — 2026/04/25
|
||||
|
||||
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.
|
||||
Phase 9 session. Spec + plan written from scratch and committed; plan corrections layered in after critical review against the actual codebase (Codex was unreachable for cross-model review, three retries failed at the ChatGPT-account-entitlement layer). Sub-phases 9a + 9b + sparkline polish landed end to end. Sub-phase 9c reduced to the Phase 8 carryover bug fix; sub-phase 9d's walkthrough sweeps deferred to Phase 10a QC.
|
||||
|
||||
## Rebrand note
|
||||
|
||||
Product rename **Kon → Corbie** still in flight. Copy in new docs is "Corbie"; codebase paths / package names / repos still carry `kon`. No rebrand work this session. See `~/.claude/projects/-home-jake-Documents-CORBEL-Main/memory/project_corbie_rebrand.md`.
|
||||
|
||||
## 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.
|
||||
### 9a — Export plumbing
|
||||
- `write_text_file_cmd` Rust command in new `src-tauri/src/commands/fs.rs`, with two unit tests (UTF-8 round-trip + bad-parent error path). Registered in `invoke_handler!`. `tempfile = "3"` added as `[dev-dependencies]` on the kon crate.
|
||||
- `src/lib/utils/saveMarkdown.ts` utility centralises `suggestedFilename`, `saveTranscriptAsMarkdown`, `exportTranscriptsToDir` (directory-mode bulk export with in-batch collision suffixing).
|
||||
- HistoryPage `exportMarkdown` no longer copies to clipboard; it opens the OS save dialog and writes the file. Cancel returns silently.
|
||||
- HistoryPage gained a slim leading checkbox per row, a bulk-action toolbar (select-all / clear / export / delete), `Esc` to clear, `Cmd/Ctrl+A` to select-all-visible when focus is inside the list and not in a text input.
|
||||
|
||||
### 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.
|
||||
### 9b — LLM content tags
|
||||
- `kon-llm` exports a new `ContentTags { topic, intent }`, an `INTENT_CLOSED_SET`, an `is_valid_intent` helper, a `CONTENT_TAGS_SYSTEM` prompt and a `CONTENT_TAGS_GRAMMAR` GBNF (recursive style matching the existing `TASK_ARRAY_GRAMMAR`).
|
||||
- `LlmEngine::extract_content_tags` method follows the same render-chat → generate → JSON-parse shape as the existing `cleanup_text` and `extract_tasks`. Truncates to the trailing 2000 chars on a UTF-8 boundary; max_tokens 96 is enough for the JSON envelope. Smoke test in `crates/llm/tests/content_tags_smoke.rs` is gated on `KON_LLM_TEST_MODEL` matching the Phase 8 pattern.
|
||||
- `extract_content_tags_cmd` Tauri wrapper bridges through `state.llm_engine` with the standard `spawn_blocking` + `PowerAssertion` guard.
|
||||
|
||||
### 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.
|
||||
### 9b structural — migration v14 + persistence wiring
|
||||
A correction layered in after the critical-review pass discovered the original Task 9 was assuming a writable `saveHistory()` path that turned out to be a no-op stub.
|
||||
- Migration v14 adds `transcripts.llm_tags TEXT NOT NULL DEFAULT ''`.
|
||||
- `kon-storage` `database.rs` SELECT statements include the column. `TranscriptRow` + `transcript_row_from` carry it. `update_transcript_meta` accepts an `Option<&str>` for `llm_tags` (sixth optional, `#[allow(too_many_arguments)]` keeps clippy happy without inverting the signature into a struct).
|
||||
- `commands/transcripts.rs` `TranscriptDto` + `UpdateTranscriptMetaRequest` add `llm_tags`; `update_transcript_meta_cmd` forwards.
|
||||
- Frontend types: `TranscriptEntry.llmTags: string[]`, `TranscriptRow.llmTags: string`, `ContentTags`, optional `TranscriptMetaPatch.llmTags`.
|
||||
- `mapTranscriptRow` hydrates `llmTags`. `saveTranscriptMeta` now also forwards `llmTags` payloads. `buildFrontmatter` unions auto + manual + LLM tags into the exported markdown frontmatter.
|
||||
- HistoryPage tag UI: per-row "Tag" button, dashed-italic LLM chips that promote-to-manual on click, top-toolbar "Tag all untagged" with progress text. Existing `addManualTag` / `removeManualTag` handlers swap their no-op `saveHistory()` calls for `saveTranscriptMeta` — picks up the latent `manualTags` persistence bug as a side effect.
|
||||
|
||||
### 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"**.
|
||||
### 9b incidental fix — Phase 8 brittle test
|
||||
`list_recent_completions_uses_local_day_boundary` failed today because its UTC-anchored `'-2 days', '+12 hours'` offset drifts across UTC midnight relative to the local-day spine the query uses. Fixed by anchoring the timestamp to the local date 2 days ago directly: `datetime(DATE('now', 'localtime', '-2 days') || ' 12:00:00')`. Phase 9 was not the cause; the test happened to fail on today's clock.
|
||||
|
||||
### 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.
|
||||
### 9c — Settings (scaled down)
|
||||
- `SettingsGroup.svelte` reusable progressive-disclosure wrapper landed (animated chevron, hover, focus-visible, prefers-reduced-motion).
|
||||
- Sparkline toggle (Phase 8 carryover backlog) relocated from the Rituals section into a new dedicated "Tasks" section. Closes the Phase 8 review note that the toggle was visually claimed by the launch-at-login subgroup.
|
||||
- **Deferred:** the deeper restructure to seven progressive-disclosure groups + search box. The 2309-line `SettingsPage.svelte` uses a hand-rolled accordion that needs careful unwinding; full restructure was too invasive to land safely in this session. `SettingsGroup` component is in tree, ready for that follow-up pass.
|
||||
|
||||
**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.
|
||||
### 9d — Polish (partial)
|
||||
- `CompletionSparkline.svelte`: friendlier sentence-form aria-label ("3 completed today. 14 total over the last 7 days." rather than a bare numeric list), per-bar `<title>` tooltips with absolute date + count, 30 ms staggered scaleY entrance animation. Earlier draft `tabindex=0` on the SVG removed: `role="img"` + aria-label is sufficient for SR navigation without putting it in the keyboard tab order (svelte-check's `noninteractive_tabindex` warning, correctly).
|
||||
- TasksPage badge: 180 ms opacity + translate-Y entrance animation on conditional mount. Both new animations respect `prefers-reduced-motion`.
|
||||
- **Deferred to Phase 10a QC:** keyboard traversal walkthrough across every page, focus-visible ring sweep, WCAG AA contrast audit in both themes, dark-mode parity check, icon-only-button aria-label audit. These are walkthrough-driven and need a running dev server to validate.
|
||||
|
||||
### 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.
|
||||
## Verification state at session end
|
||||
|
||||
| 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. |
|
||||
Fresh run on `main` tip `dd45f10`:
|
||||
|
||||
### 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.
|
||||
- `cargo fmt --check`: clean.
|
||||
- `cargo clippy --all-targets -- -D warnings`: clean.
|
||||
- `cargo test`: **277 tests pass**, 0 failed. Storage gained 1 new test (`update_transcript_meta_writes_llm_tags`), kon-tauri gained 2 (write_text_file). The Phase 8 brittle test fix is in this count.
|
||||
- `npm run check`: 0 errors, 0 warnings across 3957 files.
|
||||
- `npm run build`: clean production build via `@sveltejs/adapter-static`.
|
||||
|
||||
### 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.
|
||||
## Plan correction summary (for any future reader)
|
||||
|
||||
### 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.
|
||||
The original Phase 9 spec + plan committed at `49a795f` + `48d3db7` had three mismatches against the actual codebase, surfaced by a critical-review pass before execution. Layered as a corrections appendix in commit `3eb24f2`:
|
||||
|
||||
## What's deferred
|
||||
1. `kon-llm` is `LlmEngine::generate(prompt, config)` synchronous, not the speculated `LlamaEngine::generate_chat(messages, config).await`.
|
||||
2. `AppState.llm_engine: Arc<LlmEngine>` is direct, not behind a `RwLock`.
|
||||
3. **Structural** — `transcripts.llm_tags` requires a real SQLite migration plus Tauri command extension because the frontend `saveHistory()` is a no-op stub. Original plan assumed `manualTags`-mirroring would suffice. Migration v14 + `update_transcript_meta` extension landed as a new task to cover this. Picked up the latent `manualTags` persistence bug for free.
|
||||
|
||||
- **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.
|
||||
## Owed to Jake (next session)
|
||||
|
||||
## Gotchas discovered today
|
||||
1. **Manual dogfood walkthrough.** Cannot be driven by an automated agent. When opening Corbie next:
|
||||
- Export one transcript via the History "Export .md" button — save dialog opens, file written to chosen path. Cancel — no toast, no fallback.
|
||||
- Select 3 history rows via checkboxes — toolbar surfaces, "Export selected" writes one .md per row to a chosen folder, collisions suffixed " (2)" etc.
|
||||
- Click "Tag" on one row — within a few seconds, dashed `topic:*` and `intent:*` chips appear. Click a chip — it moves into `manualTags` (solid accent chip). Page refresh — both `manualTags` and `llmTags` survive (this is the persistence-fix outcome).
|
||||
- "Tag all untagged" runs across the corpus, progress text updates, success toast at the end.
|
||||
- Settings → new "Tasks" section appears with the sparkline toggle. Toggle off → sparkline disappears on Tasks page; badge stays. Toggle on → sparkline returns.
|
||||
- Sparkline keyboard-focus-or-hover on a bar shows the date + count tooltip. Screen reader announces the sentence-form summary.
|
||||
- `prefers-reduced-motion` set in OS — badge entrance + sparkline stagger both stop.
|
||||
|
||||
| Issue | Fix |
|
||||
2. **Phase 9 follow-up to absorb in a future polish session:**
|
||||
- Full `SettingsPage` regroup using `SettingsGroup` (already in tree), search box, Start-here always-expanded, six collapsed groups by domain.
|
||||
- The walkthrough-driven a11y sweeps from Phase 9 Tasks 14-15. Phase 10a QC will catch most; document any issues for a follow-up polish commit.
|
||||
|
||||
3. **Codex unavailability.** Three retries on the codex-rescue subagent failed because the local `~/.codex/config.toml` pins `model = "gpt-5.5"` which the ChatGPT account doesn't have access to, and explicit overrides (`gpt-4o`, `o4-mini`, `codex-mini-latest`, `gpt-5.3-codex-spark`) are also blocked at the ChatGPT-account level. Either upgrade the ChatGPT plan tier or switch Codex auth to an OpenAI API key (`codex login` with key) to unblock cross-model review on future plans.
|
||||
|
||||
## What's left for v0.1
|
||||
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| Phases 1-8 | All shipped. |
|
||||
| Phase 9 | **Mostly shipped this session.** Export plumbing, LLM content tags (with persistence), polish on sparkline + badge are live. SettingsPage deeper restructure + walkthrough a11y sweeps deferred. Roadmap entry updated. |
|
||||
| Phase 10a | QC: dogfood walkthrough (above), Rachmann's RB-08 Mac verification (parallel), cross-platform CI, a11y regression, clean-install test. Half day. |
|
||||
| Phase 10b | Kon → Corbie rename sweep: package name, all 10 crates, bundle ids, install paths, `kon.db` → `corbie.db`, event names, repo rename on both remotes. Half to 1 day. |
|
||||
| Phase 10c | Release: 0.1.0 version sync, CHANGELOG seeded from roadmap phases, release notes, tag + push. Half day. |
|
||||
|
||||
## How to resume
|
||||
### Release-blocker state
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
- **0 open CRITICAL.**
|
||||
- **1 open MAJOR.** RB-08 `power-assertion-macos-objc2` (awaits Rachmann's manual runtime verification). Gates v0.1 tagging.
|
||||
|
||||
## Repo state at session end
|
||||
|
||||
- `main` at `dd45f10`.
|
||||
- 18 Phase 9 commits (3 docs + 15 feat/polish) on top of yesterday's tip.
|
||||
- Local branches: `main` only.
|
||||
- `cargo build --workspace` green / `cargo test --workspace` green (277 passing) / `cargo clippy --workspace --all-targets -- -D warnings` clean / `cargo fmt --check` clean / `npm run check` 0/0 / `npm run build` clean.
|
||||
|
||||
## Anchors
|
||||
|
||||
- Spec: [docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md](docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md)
|
||||
- Plan: [docs/superpowers/plans/2026-04-24-phase9-polish-debt.md](docs/superpowers/plans/2026-04-24-phase9-polish-debt.md)
|
||||
- Roadmap: [docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md](docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md)
|
||||
- Previous handover: [HANDOVER-2026-04-24.md](HANDOVER-2026-04-24.md) (Phase 8)
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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!(
|
||||
@@ -374,11 +370,15 @@ fn open_and_validate(
|
||||
let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
|
||||
let requeue_tx = tx.clone();
|
||||
let dropped_chunks = Arc::new(AtomicU64::new(0));
|
||||
// Bounded channel for runtime stream errors. Capacity 16 = plenty for
|
||||
// the rare error case; if it ever fills, we drop newer errors silently
|
||||
// because they would be redundant noise in a stream that is already
|
||||
// failing. (Codex review 2026/04/17 M2)
|
||||
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(16);
|
||||
// Bounded channel for runtime stream errors. Capacity 32 = plenty for
|
||||
// the rare error case; if it ever fills, drops are reported via stderr
|
||||
// and counted in `dropped_errors` so the symptom is visible in the
|
||||
// diagnostic bundle even when the listener has gone away. Errors
|
||||
// beyond the cap are by definition redundant noise in a stream that
|
||||
// is already failing. (Codex review 2026/04/17 M2; capacity bump and
|
||||
// drop logging added 2026/04/25 audit pass.)
|
||||
let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
|
||||
let dropped_errors = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let stream = match format {
|
||||
SampleFormat::F32 => build_input_stream::<f32>(
|
||||
@@ -389,6 +389,7 @@ fn open_and_validate(
|
||||
tx,
|
||||
dropped_chunks.clone(),
|
||||
err_tx.clone(),
|
||||
dropped_errors.clone(),
|
||||
name.to_string(),
|
||||
),
|
||||
SampleFormat::I16 => build_input_stream::<i16>(
|
||||
@@ -399,6 +400,7 @@ fn open_and_validate(
|
||||
tx,
|
||||
dropped_chunks.clone(),
|
||||
err_tx.clone(),
|
||||
dropped_errors.clone(),
|
||||
name.to_string(),
|
||||
),
|
||||
SampleFormat::U16 => build_input_stream::<u16>(
|
||||
@@ -409,6 +411,7 @@ fn open_and_validate(
|
||||
tx,
|
||||
dropped_chunks.clone(),
|
||||
err_tx.clone(),
|
||||
dropped_errors.clone(),
|
||||
name.to_string(),
|
||||
),
|
||||
other => {
|
||||
@@ -507,6 +510,7 @@ fn build_input_stream<T>(
|
||||
tx: mpsc::SyncSender<AudioChunk>,
|
||||
dropped_chunks: Arc<AtomicU64>,
|
||||
err_tx: mpsc::SyncSender<CaptureRuntimeError>,
|
||||
dropped_errors: Arc<AtomicU64>,
|
||||
device_name: String,
|
||||
) -> std::result::Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
@@ -536,10 +540,24 @@ where
|
||||
// frontend can show a toast. Also keep the eprintln for ops
|
||||
// logs. (Codex review 2026/04/17 M2)
|
||||
eprintln!("[kon-audio] capture error: {err}");
|
||||
let _ = err_tx.try_send(CaptureRuntimeError {
|
||||
device_name: err_device_name.clone(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
if err_tx
|
||||
.try_send(CaptureRuntimeError {
|
||||
device_name: err_device_name.clone(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Channel full — listener has stalled or detached. Note
|
||||
// it in stderr and the dropped-errors counter so the
|
||||
// diagnostic bundle still shows the symptom even if the
|
||||
// frontend never received the typed event.
|
||||
let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
|
||||
eprintln!(
|
||||
"[kon-audio] capture error channel full; dropped error #{} for device '{}'",
|
||||
prior + 1,
|
||||
err_device_name,
|
||||
);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,13 @@ use kon_core::types::AudioSamples;
|
||||
/// input silently returned `Ok` with whatever had decoded before the
|
||||
/// failure — flagged by the 2026-04-22 review (RB-09).
|
||||
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
||||
decode_audio_file_limited(path, None)
|
||||
}
|
||||
|
||||
pub fn decode_audio_file_limited(
|
||||
path: &Path,
|
||||
max_duration_secs: Option<f64>,
|
||||
) -> Result<AudioSamples> {
|
||||
let file = File::open(path)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
@@ -30,13 +37,48 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
decode_media_stream(mss, &hint)
|
||||
decode_media_stream(mss, &hint, max_duration_secs)
|
||||
}
|
||||
|
||||
pub fn probe_audio_duration_secs(path: &Path) -> Result<Option<f64>> {
|
||||
let file = File::open(path)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
|
||||
let track = probed
|
||||
.format
|
||||
.default_track()
|
||||
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
|
||||
Ok(track
|
||||
.codec_params
|
||||
.n_frames
|
||||
.map(|frames| frames as f64 / sample_rate as f64))
|
||||
}
|
||||
|
||||
/// Decode from an already-constructed `MediaSourceStream`. Split out so
|
||||
/// tests can inject a custom `MediaSource` (for example, one that
|
||||
/// returns a mid-stream I/O error) to verify error propagation.
|
||||
fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSamples> {
|
||||
fn decode_media_stream(
|
||||
mss: MediaSourceStream,
|
||||
hint: &Hint,
|
||||
max_duration_secs: Option<f64>,
|
||||
) -> Result<AudioSamples> {
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
hint,
|
||||
@@ -61,6 +103,7 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
}
|
||||
|
||||
let track_id = track.id;
|
||||
let max_samples = max_duration_secs.map(|secs| (secs * sample_rate as f64).ceil() as usize);
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
@@ -111,6 +154,15 @@ fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSampl
|
||||
samples.push(sum / channels as f32);
|
||||
}
|
||||
}
|
||||
if max_samples
|
||||
.map(|limit| samples.len() > limit)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(KonError::AudioDecodeFailed(format!(
|
||||
"Audio is longer than the {:.0} minute import limit",
|
||||
max_duration_secs.unwrap_or(0.0) / 60.0
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
@@ -222,7 +274,7 @@ mod tests {
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("wav");
|
||||
|
||||
let result = decode_media_stream(mss, &hint);
|
||||
let result = decode_media_stream(mss, &hint, None);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"mid-stream I/O error must surface, got: {result:?}"
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod wav;
|
||||
|
||||
pub use capture::{AudioChunk, CaptureRuntimeError, DeviceInfo, MicrophoneCapture};
|
||||
pub use concurrency::decode_and_resample;
|
||||
pub use decode::decode_audio_file;
|
||||
pub use decode::{decode_audio_file, decode_audio_file_limited, probe_audio_duration_secs};
|
||||
pub use resample::resample_to_16khz;
|
||||
pub use streaming_resample::StreamingResampler;
|
||||
pub use vad::SpeechDetector;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod constants;
|
||||
pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod model_registry;
|
||||
pub mod paths;
|
||||
pub mod process_watch;
|
||||
pub mod recommendation;
|
||||
pub mod types;
|
||||
|
||||
@@ -40,8 +40,8 @@ pub struct ModelFile {
|
||||
pub filename: &'static str,
|
||||
pub url: &'static str,
|
||||
pub size: Megabytes,
|
||||
/// SHA256 hex digest for integrity verification. None to skip check.
|
||||
pub sha256: Option<&'static str>,
|
||||
/// SHA256 hex digest for integrity verification.
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
/// All metadata for a single downloadable model.
|
||||
@@ -74,27 +74,27 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
files: vec![
|
||||
ModelFile {
|
||||
filename: "encoder-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/encoder-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/encoder-model.int8.onnx",
|
||||
size: Megabytes(620),
|
||||
sha256: None,
|
||||
sha256: "3e0581fda6ab843888b51e56d7ee78b6d5bc3237ec113af1f732d1d5286aa155",
|
||||
},
|
||||
ModelFile {
|
||||
filename: "decoder_joint-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/decoder_joint-model.int8.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/decoder_joint-model.int8.onnx",
|
||||
size: Megabytes(3),
|
||||
sha256: None,
|
||||
sha256: "a449f49acd68979d418651dd2dcb737cc0f1bf0225e009e29ee326354edbf7d3",
|
||||
},
|
||||
ModelFile {
|
||||
filename: "nemo128.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/nemo128.onnx",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/nemo128.onnx",
|
||||
size: Megabytes(1),
|
||||
sha256: None,
|
||||
sha256: "a9fde1486ebfcc08f328d75ad4610c67835fea58c73ba57e3209a6f6cf019e9f",
|
||||
},
|
||||
ModelFile {
|
||||
filename: "vocab.txt",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/main/vocab.txt",
|
||||
url: "https://huggingface.co/istupakov/parakeet-tdt-0.6b-v2-onnx/resolve/0bbb45a3365852604aef28b538a8f066f4ccaa85/vocab.txt",
|
||||
size: Megabytes(1),
|
||||
sha256: None,
|
||||
sha256: "ec182b70dd42113aff6c5372c75cac58c952443eb22322f57bbd7f53977d497d",
|
||||
},
|
||||
],
|
||||
description: "Fastest local model — near-instant transcription",
|
||||
@@ -110,9 +110,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-tiny.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.en.bin",
|
||||
size: Megabytes(75),
|
||||
sha256: None,
|
||||
sha256: "921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f",
|
||||
}],
|
||||
description: "Bundled with app — works instantly",
|
||||
},
|
||||
@@ -127,9 +127,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-base.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.en.bin",
|
||||
size: Megabytes(142),
|
||||
sha256: None,
|
||||
sha256: "a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002",
|
||||
}],
|
||||
description: "Good balance of speed and accuracy",
|
||||
},
|
||||
@@ -144,9 +144,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-small.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-small.en.bin",
|
||||
size: Megabytes(466),
|
||||
sha256: None,
|
||||
sha256: "c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d",
|
||||
}],
|
||||
description: "Accuracy-first English transcription",
|
||||
},
|
||||
@@ -161,9 +161,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-distil-small.en.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/main/ggml-distil-small.en.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-small.en/resolve/9e4a67ca4569c30be43a3fe7fba1621e504f0093/ggml-distil-small.en.bin",
|
||||
size: Megabytes(336),
|
||||
sha256: None,
|
||||
sha256: "7691eb11167ab7aaf6b3e05d8266f2fd9ad89c550e433f86ac266ebdee6c970a",
|
||||
}],
|
||||
description: "Small accuracy, ~6\u{00d7} faster — distilled variant",
|
||||
},
|
||||
@@ -178,9 +178,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-medium.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-medium.en.bin",
|
||||
size: Megabytes(1500),
|
||||
sha256: None,
|
||||
sha256: "cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356",
|
||||
}],
|
||||
description: "Best Whisper accuracy — needs 4+ GB RAM",
|
||||
},
|
||||
@@ -195,9 +195,9 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
||||
languages: LanguageSupport::EnglishOnly,
|
||||
files: vec![ModelFile {
|
||||
filename: "ggml-distil-large-v3.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/main/ggml-distil-large-v3.bin",
|
||||
url: "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/0d78dd96ed9fc152325f63b53788fec3b43de031/ggml-distil-large-v3.bin",
|
||||
size: Megabytes(1550),
|
||||
sha256: None,
|
||||
sha256: "2883a11b90fb10ed592d826edeaee7d2929bf1ab985109fe9e1e7b4d2b69a298",
|
||||
}],
|
||||
description: "Near large-v3 accuracy at ~6\u{00d7} the speed",
|
||||
},
|
||||
@@ -213,3 +213,35 @@ pub fn all_models() -> &'static [ModelEntry] {
|
||||
pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> {
|
||||
ALL_MODELS.iter().find(|m| &m.id == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::all_models;
|
||||
|
||||
#[test]
|
||||
fn every_model_file_has_sha256_and_pinned_url() {
|
||||
for model in all_models() {
|
||||
for file in &model.files {
|
||||
assert_eq!(
|
||||
file.sha256.len(),
|
||||
64,
|
||||
"{} / {} must carry a SHA256 digest",
|
||||
model.id,
|
||||
file.filename
|
||||
);
|
||||
assert!(
|
||||
file.sha256.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"{} / {} SHA256 must be hex",
|
||||
model.id,
|
||||
file.filename
|
||||
);
|
||||
assert!(
|
||||
!file.url.contains("/resolve/main/"),
|
||||
"{} / {} must pin a Hugging Face revision",
|
||||
model.id,
|
||||
file.filename
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
125
crates/core/src/paths.rs
Normal file
125
crates/core/src/paths.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::types::ModelId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AppPaths {
|
||||
app_data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AppPaths {
|
||||
pub fn current() -> Self {
|
||||
Self {
|
||||
app_data_dir: resolve_app_data_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_data_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.clone()
|
||||
}
|
||||
|
||||
pub fn database_path(&self) -> PathBuf {
|
||||
self.app_data_dir.join("kon.db")
|
||||
}
|
||||
|
||||
pub fn recordings_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("recordings")
|
||||
}
|
||||
|
||||
pub fn crashes_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("crashes")
|
||||
}
|
||||
|
||||
pub fn logs_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("logs")
|
||||
}
|
||||
|
||||
pub fn diagnostic_reports_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("diagnostic-reports")
|
||||
}
|
||||
|
||||
pub fn models_dir(&self) -> PathBuf {
|
||||
self.app_data_dir.join("models")
|
||||
}
|
||||
|
||||
pub fn speech_model_dir(&self, id: &ModelId) -> PathBuf {
|
||||
self.models_dir().join(id.as_str())
|
||||
}
|
||||
|
||||
pub fn llm_models_dir(&self) -> PathBuf {
|
||||
self.models_dir().join("llm")
|
||||
}
|
||||
|
||||
pub fn migration_sentinel(&self, name: &str) -> PathBuf {
|
||||
self.app_data_dir.join(format!(".{name}.sentinel"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_paths() -> AppPaths {
|
||||
AppPaths::current()
|
||||
}
|
||||
|
||||
pub fn app_data_dir() -> PathBuf {
|
||||
app_paths().app_data_dir()
|
||||
}
|
||||
|
||||
fn resolve_app_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
return PathBuf::from(local_app_data).join("kon");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Kon");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
let legacy = PathBuf::from(&home).join(".kon");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("kon");
|
||||
}
|
||||
}
|
||||
PathBuf::from(home).join(".local").join("share").join("kon")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppPaths;
|
||||
use crate::types::ModelId;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn derives_all_paths_from_one_base() {
|
||||
let paths = AppPaths {
|
||||
app_data_dir: PathBuf::from("/tmp/kon-test"),
|
||||
};
|
||||
assert_eq!(paths.database_path(), PathBuf::from("/tmp/kon-test/kon.db"));
|
||||
assert_eq!(
|
||||
paths.speech_model_dir(&ModelId::new("whisper-base-en")),
|
||||
PathBuf::from("/tmp/kon-test/models/whisper-base-en")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.llm_models_dir(),
|
||||
PathBuf::from("/tmp/kon-test/models/llm")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,56 @@
|
||||
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
|
||||
|
||||
/// Reusable wrapper around a `sysinfo::System` whose process table is
|
||||
/// refreshed in place on every poll, instead of allocating a fresh one.
|
||||
///
|
||||
/// On a busy host (~300 processes), `System::new_with_specifics` followed by
|
||||
/// `refresh_processes` walks `/proc` cold and costs ~50–100 ms; reusing the
|
||||
/// same instance reuses sysinfo's per-process bookkeeping so subsequent
|
||||
/// refreshes are dominated by diffing rather than allocation. The Tauri
|
||||
/// host holds one of these behind a `Mutex` for the meeting-detection
|
||||
/// command to call every 15 s.
|
||||
pub struct ProcessLister {
|
||||
system: System,
|
||||
}
|
||||
|
||||
impl Default for ProcessLister {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessLister {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
system: System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the process table in place and return the current
|
||||
/// lowercased executable names.
|
||||
pub fn snapshot(&mut self) -> Vec<String> {
|
||||
self.system
|
||||
.refresh_processes(ProcessesToUpdate::All, true);
|
||||
self.system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| process.name().to_string_lossy().to_lowercase())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the current process list's executable/command names. Lowercased
|
||||
/// for case-insensitive pattern matching.
|
||||
///
|
||||
/// Convenience wrapper that allocates a fresh `ProcessLister` per call.
|
||||
/// Hot paths (the meeting-detection poller) should hold a long-lived
|
||||
/// `ProcessLister` and call `snapshot()` directly to avoid the per-call
|
||||
/// allocation of `System`'s internal bookkeeping.
|
||||
pub fn list_running_process_names() -> Vec<String> {
|
||||
let mut system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
|
||||
);
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| process.name().to_string_lossy().to_lowercase())
|
||||
.collect()
|
||||
ProcessLister::new().snapshot()
|
||||
}
|
||||
|
||||
/// Match a snapshot of process names against case-insensitive substring
|
||||
|
||||
@@ -317,10 +317,26 @@ async fn device_listener(
|
||||
&& alt_held == combo.alt
|
||||
&& super_held == combo.super_key
|
||||
{
|
||||
if pressed {
|
||||
let _ = event_tx.send(HotkeyEvent::Pressed).await;
|
||||
let to_send = if pressed {
|
||||
Some(HotkeyEvent::Pressed)
|
||||
} else if released {
|
||||
let _ = event_tx.send(HotkeyEvent::Released).await;
|
||||
Some(HotkeyEvent::Released)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = to_send {
|
||||
if event_tx.send(event).await.is_err() {
|
||||
// Receiver was dropped without an
|
||||
// explicit None-on-hotkey-rx
|
||||
// shutdown. Log once and exit so
|
||||
// the listener doesn't spin
|
||||
// sending into a closed channel.
|
||||
log::warn!(
|
||||
"Hotkey event channel closed; \
|
||||
listener for device exiting"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,14 +359,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)]
|
||||
|
||||
@@ -3,11 +3,22 @@ name = "kon-llm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
# Default desktop build keeps the existing openmp + vulkan acceleration.
|
||||
# Mobile / CPU-only targets can drop one or both via:
|
||||
# cargo build -p kon-llm --no-default-features
|
||||
# These are independent so an Android Vulkan build can opt into vulkan
|
||||
# without openmp (the NDK ships OpenMP libs but the toolchain configuration
|
||||
# is fragile across NDK versions).
|
||||
default = ["gpu-vulkan", "openmp"]
|
||||
gpu-vulkan = ["llama-cpp-2/vulkan"]
|
||||
openmp = ["llama-cpp-2/openmp"]
|
||||
|
||||
[dependencies]
|
||||
dirs = "6"
|
||||
kon-core = { path = "../core" }
|
||||
encoding_rs = "0.8"
|
||||
futures-util = "0.3"
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] }
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false }
|
||||
num_cpus = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
// Phase 9 content-tag extraction. Restricts the model output to a
|
||||
// strict {topic, intent} JSON object where topic is a lowercase
|
||||
// hyphen-joined slug of at least 3 chars (no upper bound is encoded
|
||||
// in the grammar — max_tokens caps it in practice) and intent is one
|
||||
// of the six closed-set values. Recursive `topic-rest` keeps the
|
||||
// shape compatible with the existing GBNF style in this file.
|
||||
pub const CONTENT_TAGS_GRAMMAR: &str = r##"
|
||||
root ::= "{" ws "\"topic\":" ws topic-str ws "," ws "\"intent\":" ws intent ws "}" ws
|
||||
topic-str ::= "\"" topic-char topic-char topic-char topic-rest "\""
|
||||
topic-rest ::= "" | topic-char topic-rest
|
||||
topic-char ::= [a-z0-9-]
|
||||
intent ::= "\"planning\"" | "\"reflection\"" | "\"venting\"" | "\"capture\"" | "\"decision\"" | "\"question\""
|
||||
ws ::= ([ \t\n] ws)?
|
||||
"##;
|
||||
|
||||
pub const TASK_ARRAY_GRAMMAR: &str = r#"
|
||||
root ::= "[" ws string ws "," ws string ws "," ws string rest3 ws "]"
|
||||
rest3 ::= "" | "," ws string rest4
|
||||
|
||||
@@ -15,7 +15,9 @@ pub mod grammars;
|
||||
pub mod model_manager;
|
||||
pub mod prompts;
|
||||
|
||||
pub use grammars::CONTENT_TAGS_GRAMMAR;
|
||||
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
|
||||
pub use prompts::{is_valid_intent, ContentTags, CONTENT_TAGS_SYSTEM, INTENT_CLOSED_SET};
|
||||
|
||||
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
|
||||
const MAX_CONTEXT_TOKENS: u32 = 8192;
|
||||
@@ -240,11 +242,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 +282,85 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
pub fn extract_tasks(&self, transcript: &str) -> Result<Vec<String>, EngineError> {
|
||||
self.extract_tasks_with_feedback(transcript, &[])
|
||||
}
|
||||
|
||||
/// Phase 9 content-tag extraction. Emits a single (topic, intent)
|
||||
/// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the
|
||||
/// trailing 2000 chars of the transcript so the prompt budget
|
||||
/// stays well under any model's context window. Determinism is
|
||||
/// enforced by temperature 0.0 and the closed-set intent grammar
|
||||
/// rule; on the rare case the model emits a parse-able-but-out-of-
|
||||
/// set intent, we re-validate with `is_valid_intent` and bubble
|
||||
/// `InvalidJson` so the frontend toasts a clear error.
|
||||
pub fn extract_content_tags(
|
||||
&self,
|
||||
transcript: &str,
|
||||
) -> Result<prompts::ContentTags, EngineError> {
|
||||
if transcript.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
return Err(EngineError::Inference("empty transcript".into()));
|
||||
}
|
||||
|
||||
// Truncate to the last 2000 chars on a UTF-8 char boundary so
|
||||
// we don't slice through a multi-byte sequence.
|
||||
const MAX_CHARS: usize = 2000;
|
||||
let tail = if transcript.len() > MAX_CHARS {
|
||||
let mut adj = transcript.len() - MAX_CHARS;
|
||||
while adj < transcript.len() && !transcript.is_char_boundary(adj) {
|
||||
adj += 1;
|
||||
}
|
||||
&transcript[adj..]
|
||||
} else {
|
||||
transcript
|
||||
};
|
||||
|
||||
let model = self.loaded_model_arc()?;
|
||||
let prompt = render_chat_prompt(
|
||||
&model,
|
||||
&[
|
||||
("system", prompts::EXTRACT_TASKS_SYSTEM),
|
||||
("system", prompts::CONTENT_TAGS_SYSTEM),
|
||||
("user", &format!("Transcript:\n{tail}")),
|
||||
],
|
||||
)?;
|
||||
let raw = self.generate(
|
||||
&prompt,
|
||||
&GenerationConfig {
|
||||
max_tokens: 96,
|
||||
temperature: 0.0,
|
||||
stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()],
|
||||
grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()),
|
||||
},
|
||||
)?;
|
||||
|
||||
let tags: prompts::ContentTags = serde_json::from_str(raw.trim())
|
||||
.map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;
|
||||
if !prompts::is_valid_intent(&tags.intent) {
|
||||
return Err(EngineError::InvalidJson(format!(
|
||||
"intent out of closed set: {}",
|
||||
tags.intent,
|
||||
)));
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// 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", system.as_str()),
|
||||
("user", &format!("Transcript:\n{transcript}")),
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -158,6 +159,36 @@ const ALL_MODELS: &[LlmModelId] = &[
|
||||
LlmModelId::Qwen3_14BQ5,
|
||||
];
|
||||
|
||||
static ACTIVE_DOWNLOADS: LazyLock<Mutex<std::collections::HashSet<LlmModelId>>> =
|
||||
LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
|
||||
|
||||
struct DownloadReservation {
|
||||
id: LlmModelId,
|
||||
}
|
||||
|
||||
impl DownloadReservation {
|
||||
fn acquire(id: LlmModelId) -> Result<Self, DownloadError> {
|
||||
let mut active = ACTIVE_DOWNLOADS
|
||||
.lock()
|
||||
.map_err(|_| DownloadError::Http("download lock poisoned".into()))?;
|
||||
if !active.insert(id) {
|
||||
return Err(DownloadError::Http(format!(
|
||||
"download already in progress for {}",
|
||||
id.as_str()
|
||||
)));
|
||||
}
|
||||
Ok(Self { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DownloadReservation {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
|
||||
active.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_models() -> &'static [LlmModelId] {
|
||||
ALL_MODELS
|
||||
}
|
||||
@@ -189,20 +220,7 @@ pub fn recommend_tier(total_ram_bytes: u64, total_vram_bytes: Option<u64>) -> Ll
|
||||
}
|
||||
|
||||
pub fn model_dir() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
std::env::var("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("kon")
|
||||
.join("models")
|
||||
.join("llm")
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".kon")
|
||||
.join("models")
|
||||
.join("llm")
|
||||
}
|
||||
kon_core::paths::app_paths().llm_models_dir()
|
||||
}
|
||||
|
||||
pub fn model_path(id: LlmModelId) -> PathBuf {
|
||||
@@ -235,6 +253,7 @@ pub async fn download_model<F>(id: LlmModelId, on_progress: F) -> Result<(), Dow
|
||||
where
|
||||
F: FnMut(u64, u64) + Send + 'static,
|
||||
{
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let dest = model_path(id);
|
||||
tokio::fs::create_dir_all(model_dir()).await?;
|
||||
|
||||
|
||||
@@ -4,9 +4,152 @@ between 3 and 7 concrete, physical micro-steps. Each step must be a short \
|
||||
imperative sentence, actionable today, with no commentary. Output ONLY a \
|
||||
JSON array of strings.";
|
||||
|
||||
// Phase 9 content-tag extraction. The model emits a {topic, intent}
|
||||
// JSON pair under a strict GBNF (see grammars::CONTENT_TAGS_GRAMMAR).
|
||||
// CONTENT_TAGS_SYSTEM is the system message; the user message wraps
|
||||
// the transcript text.
|
||||
pub const CONTENT_TAGS_SYSTEM: &str = "\
|
||||
You tag a transcript with ONE topic and ONE intent. \
|
||||
TOPIC is a 1 to 3 token lowercase hyphen-joined noun phrase naming the \
|
||||
dominant subject. Examples: interview-prep, grant-application, \
|
||||
daily-standup. \
|
||||
INTENT is exactly one of: planning, reflection, venting, capture, \
|
||||
decision, question. \
|
||||
Return JSON only, with this exact shape: \
|
||||
{\"topic\":\"...\",\"intent\":\"...\"}";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentTags {
|
||||
pub topic: String,
|
||||
pub intent: String,
|
||||
}
|
||||
|
||||
pub const INTENT_CLOSED_SET: &[&str] = &[
|
||||
"planning",
|
||||
"reflection",
|
||||
"venting",
|
||||
"capture",
|
||||
"decision",
|
||||
"question",
|
||||
];
|
||||
|
||||
pub fn is_valid_intent(s: &str) -> bool {
|
||||
INTENT_CLOSED_SET.contains(&s)
|
||||
}
|
||||
|
||||
pub const EXTRACT_TASKS_SYSTEM: &str = "\
|
||||
You are a task-extraction assistant. Given a transcript of spoken notes, \
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/llm/tests/content_tags_smoke.rs
Normal file
48
crates/llm/tests/content_tags_smoke.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Smoke test for Phase 9 LlmEngine::extract_content_tags.
|
||||
//!
|
||||
//! Gated behind the same `KON_LLM_TEST_MODEL` env var as the existing
|
||||
//! smoke.rs test so neither runs in default `cargo test` runs (model
|
||||
//! load is heavy). Run explicitly with:
|
||||
//!
|
||||
//! KON_LLM_TEST_MODEL=/path/to/model.gguf cargo test -p kon-llm \
|
||||
//! --test content_tags_smoke -- --nocapture
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kon_llm::{is_valid_intent, LlmEngine, LlmModelId};
|
||||
|
||||
#[test]
|
||||
fn extract_content_tags_returns_valid_pair() {
|
||||
let model_path = match env::var("KON_LLM_TEST_MODEL") {
|
||||
Ok(path) => PathBuf::from(path),
|
||||
Err(_) => {
|
||||
eprintln!("KON_LLM_TEST_MODEL not set — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let engine = LlmEngine::new();
|
||||
engine
|
||||
.load_model(LlmModelId::Qwen3_1_7B_Q4, &model_path, true)
|
||||
.expect("load model");
|
||||
|
||||
let transcript = "Tomorrow I need to run through the grant application one more time \
|
||||
and make sure the figures add up. I also need to book a slot with \
|
||||
Rachmann for the Mac test and email Andrew about the meeting window.";
|
||||
let tags = engine
|
||||
.extract_content_tags(transcript)
|
||||
.expect("extract_content_tags");
|
||||
|
||||
assert!(tags.topic.len() >= 3, "topic present: {tags:?}");
|
||||
assert!(
|
||||
tags.topic
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
|
||||
"topic lowercase + slugged: {tags:?}",
|
||||
);
|
||||
assert!(
|
||||
is_valid_intent(&tags.intent),
|
||||
"intent in closed set: {tags:?}",
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,15 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let db_path = kon_storage::database_path();
|
||||
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
|
||||
let pool = kon_storage::init(&db_path).await?;
|
||||
eprintln!(
|
||||
"[kon-mcp] opening Kon database at {} (read-only)",
|
||||
db_path.display()
|
||||
);
|
||||
// Open read-only at the connection level so the MCP server cannot write
|
||||
// to the user's database, regardless of which tools the dispatcher
|
||||
// exposes. Migrations are deliberately skipped — this binary never owns
|
||||
// the schema; the main app is the single migration writer.
|
||||
let pool = kon_storage::init_readonly(&db_path).await?;
|
||||
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
|
||||
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
|
||||
@@ -18,6 +18,9 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio",
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["rt", "sync", "macros"] }
|
||||
|
||||
# Serialisation (DailyCompletionCount exposed to frontend via Tauri commands)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,28 @@
|
||||
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`
|
||||
/// - 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.
|
||||
/// - Other Unix: `~/.kon/`
|
||||
///
|
||||
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
|
||||
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
|
||||
/// path logic across crates.
|
||||
pub fn app_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
return PathBuf::from(local_app_data).join("kon");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
return PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Kon");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
|
||||
// Honour the legacy ~/.kon/ if it exists on disk so existing
|
||||
// installs are not orphaned. New installs follow XDG.
|
||||
let legacy = PathBuf::from(&home).join(".kon");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
// XDG Base Directory: $XDG_DATA_HOME/kon or default ~/.local/share/kon
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("kon");
|
||||
}
|
||||
}
|
||||
return PathBuf::from(home).join(".local").join("share").join("kon");
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
}
|
||||
kon_core::paths::app_paths().app_data_dir()
|
||||
}
|
||||
|
||||
/// Path to the SQLite database file.
|
||||
pub fn database_path() -> PathBuf {
|
||||
app_data_dir().join("kon.db")
|
||||
kon_core::paths::app_paths().database_path()
|
||||
}
|
||||
|
||||
/// Directory for saved audio recordings.
|
||||
pub fn recordings_dir() -> PathBuf {
|
||||
app_data_dir().join("recordings")
|
||||
kon_core::paths::app_paths().recordings_dir()
|
||||
}
|
||||
|
||||
/// Directory for crash dumps written by the Rust panic hook.
|
||||
/// Each crash is a single text file: `<unix-ts>-<short-id>.crash`.
|
||||
/// Used by the diagnostic-report bundler in Settings → About.
|
||||
pub fn crashes_dir() -> PathBuf {
|
||||
app_data_dir().join("crashes")
|
||||
kon_core::paths::app_paths().crashes_dir()
|
||||
}
|
||||
|
||||
/// Directory for the rolling Rust log file (kon.log + rotated kon.log.1, etc).
|
||||
/// Subscribers configured in src-tauri/src/lib.rs at startup.
|
||||
pub fn logs_dir() -> PathBuf {
|
||||
app_data_dir().join("logs")
|
||||
kon_core::paths::app_paths().logs_dir()
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
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,
|
||||
TaskRow, TranscriptRow,
|
||||
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
|
||||
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
|
||||
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
|
||||
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
||||
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
||||
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
||||
set_setting,
|
||||
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
||||
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
||||
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
||||
RecordFeedbackParams, TaskRow, TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -334,6 +334,149 @@ 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);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
11,
|
||||
"tasks: energy tagging for match-my-energy sort",
|
||||
r#"
|
||||
-- Phase 3 of the feature-complete roadmap: replaces the cut
|
||||
-- temptation-bundling feature with a deterministic client-side
|
||||
-- sort that matches tasks to the user's current energy state.
|
||||
-- NULL is the expected normal case — users who never tag get
|
||||
-- Medium-equivalent treatment at sort time (see Match-my-energy
|
||||
-- logic in src/lib/pages/TasksPage.svelte).
|
||||
--
|
||||
-- profile_id is deliberately absent from the index: tasks
|
||||
-- currently carry no profile_id column, so a per-profile index
|
||||
-- is out of scope until the broader task → profile migration
|
||||
-- lands. See HANDOVER deferred list.
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN energy TEXT
|
||||
CHECK (energy IS NULL OR energy IN ('high', 'medium', 'brain_dead'));
|
||||
|
||||
CREATE INDEX idx_tasks_energy_created
|
||||
ON tasks(energy, created_at DESC);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
12,
|
||||
"implementation intentions: if-then automation rules",
|
||||
r#"
|
||||
-- Phase 7 of the feature-complete roadmap. Rules are local-only,
|
||||
-- user-authored implementation intentions: "if this happens, then
|
||||
-- do this small thing". Execution stays in the frontend event bus;
|
||||
-- SQLite owns the durable definition and the once-per-day marker
|
||||
-- for time-of-day rules.
|
||||
CREATE TABLE implementation_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (enabled IN (0, 1)),
|
||||
trigger_kind TEXT NOT NULL
|
||||
CHECK (trigger_kind IN (
|
||||
'time_of_day',
|
||||
'task_completed',
|
||||
'morning_triage_finished'
|
||||
)),
|
||||
trigger_value TEXT NOT NULL DEFAULT '',
|
||||
actions_json TEXT NOT NULL DEFAULT '[]',
|
||||
last_fired_key TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_implementation_rules_enabled_trigger
|
||||
ON implementation_rules(enabled, trigger_kind);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
13,
|
||||
"gamification: auto_completed flag for cascade-completed parents",
|
||||
r#"
|
||||
-- Phase 8 of the feature-complete roadmap. Parents that close via
|
||||
-- the complete_subtask_and_check_parent cascade must not count
|
||||
-- towards daily completion totals. The user already got credit
|
||||
-- for ticking the subtask. This column distinguishes manual
|
||||
-- completions (0) from cascade completions (1). The daily-count
|
||||
-- query then excludes auto_completed = 1.
|
||||
--
|
||||
-- Partial index keeps the index small: only completed rows occupy
|
||||
-- it, since uncompleted rows have done_at IS NULL.
|
||||
ALTER TABLE tasks ADD COLUMN auto_completed INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (auto_completed IN (0, 1));
|
||||
|
||||
CREATE INDEX idx_tasks_done_at_auto_completed
|
||||
ON tasks(done_at, auto_completed)
|
||||
WHERE done_at IS NOT NULL;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
14,
|
||||
"transcripts: llm_tags column for Phase 9 LLM content tags",
|
||||
r#"
|
||||
-- Phase 9 of the feature-complete roadmap. AI-generated content
|
||||
-- tags (topic:* and intent:*) are stored alongside manual_tags as
|
||||
-- a comma-joined string, mirroring how manual_tags persists. Pre-
|
||||
-- existing rows default to empty string. The frontend chips loop
|
||||
-- handles "" as "no tags".
|
||||
ALTER TABLE transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT '';
|
||||
"#,
|
||||
),
|
||||
(
|
||||
15,
|
||||
"transcripts: composite (profile_id, created_at DESC) index",
|
||||
r#"
|
||||
-- Performance index for the dominant transcripts query path:
|
||||
-- WHERE profile_id = ? ORDER BY created_at DESC LIMIT ?
|
||||
-- The standalone idx_transcripts_profile_id and idx_transcripts_created
|
||||
-- forced SQLite to either filter by profile then sort, or scan the date
|
||||
-- index and filter — fine at hundreds of rows, painful past a few thousand.
|
||||
-- A composite index covers both predicates in one ordered seek.
|
||||
CREATE INDEX IF NOT EXISTS idx_transcripts_profile_created
|
||||
ON transcripts(profile_id, created_at DESC);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -483,7 +626,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 9);
|
||||
assert_eq!(count, 15);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -502,7 +645,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 9);
|
||||
assert_eq!(count, 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -530,6 +673,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_implementation_rules_adds_rule_table() {
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(implementation_rules)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
for col in [
|
||||
"id",
|
||||
"enabled",
|
||||
"trigger_kind",
|
||||
"trigger_value",
|
||||
"actions_json",
|
||||
"last_fired_key",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&col.to_string()),
|
||||
"implementation_rules must have {col}; got {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let rejected = sqlx::query(
|
||||
"INSERT INTO implementation_rules (id, trigger_kind, actions_json)
|
||||
VALUES ('bad', 'calendar_event', '[]')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(
|
||||
rejected.is_err(),
|
||||
"trigger_kind CHECK constraint must reject unknown triggers"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcripts_meta_adds_columns() {
|
||||
// Task 2.5 — verify starred / manual_tags / template / language /
|
||||
@@ -859,8 +1040,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 +1055,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 +1074,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,15 +1090,96 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_v13_adds_auto_completed_column() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
// Column exists.
|
||||
let info = sqlx::query("PRAGMA table_info(tasks)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("pragma");
|
||||
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n == "auto_completed"),
|
||||
"expected auto_completed column, got {names:?}"
|
||||
);
|
||||
|
||||
// Existing completed rows default to 0. Insert a pre-existing-looking
|
||||
// task via raw SQL to simulate a row from before the migration.
|
||||
sqlx::query(
|
||||
"INSERT INTO tasks (id, text, bucket, done, done_at) \
|
||||
VALUES ('t1', 'pre-existing', 'inbox', 1, '2026-04-20 12:00:00')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 't1'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("query");
|
||||
assert_eq!(auto, 0, "pre-existing completed rows must default to 0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_v15_creates_profile_created_index() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
// Index exists by name.
|
||||
let names: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT name FROM sqlite_master \
|
||||
WHERE type = 'index' AND tbl_name = 'transcripts'",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("read indexes");
|
||||
assert!(
|
||||
names.iter().any(|n| n == "idx_transcripts_profile_created"),
|
||||
"expected composite (profile_id, created_at) index, got {names:?}",
|
||||
);
|
||||
|
||||
// Query planner picks the composite index for the dominant
|
||||
// profile-scoped, date-ordered list query. EXPLAIN QUERY PLAN
|
||||
// returns (id, parent, notused, detail) — we want detail.
|
||||
let plan_rows = sqlx::query(
|
||||
"EXPLAIN QUERY PLAN \
|
||||
SELECT id FROM transcripts \
|
||||
WHERE profile_id = ? ORDER BY created_at DESC LIMIT 50",
|
||||
)
|
||||
.bind(crate::DEFAULT_PROFILE_ID)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("explain");
|
||||
let plan: Vec<String> = plan_rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("detail"))
|
||||
.collect();
|
||||
assert!(
|
||||
plan.iter().any(|row| row.contains("idx_transcripts_profile_created")),
|
||||
"planner should use the composite index, got plan: {plan:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,19 @@ description = "Speech-to-text engine wrappers, model management, and inference c
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Whisper backend (direct whisper-rs, vulkan-accelerated). Default on —
|
||||
# gating it exists so a future Windows non-AVX2 build, or a cloud-only
|
||||
# ASR configuration, can drop whisper-rs-sys entirely per brief item
|
||||
# #13. Disabling this feature also drops the WhisperRsBackend module
|
||||
# and the load_whisper entry point.
|
||||
default = ["whisper"]
|
||||
# Whisper backend (direct whisper-rs). Default on — gating it exists so
|
||||
# a future Windows non-AVX2 build, or a cloud-only ASR configuration,
|
||||
# can drop whisper-rs-sys entirely per brief item #13. Disabling this
|
||||
# feature also drops the WhisperRsBackend module and the load_whisper
|
||||
# entry point.
|
||||
#
|
||||
# `whisper-vulkan` is a separate feature so a non-Vulkan target (Android
|
||||
# without GPU drivers, a CPU-only Windows build) can pull in whisper-rs
|
||||
# but skip the Vulkan backend. Build CPU-only with:
|
||||
# cargo build -p kon-transcription --no-default-features --features whisper
|
||||
default = ["whisper", "whisper-vulkan"]
|
||||
whisper = ["dep:whisper-rs", "dep:num_cpus"]
|
||||
whisper-vulkan = ["whisper-rs?/vulkan"]
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
@@ -24,14 +30,15 @@ transcribe-rs = { version = "0.3", default-features = false, features = ["onnx"]
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
# Model downloads
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Download integrity verification
|
||||
sha2 = "0.10"
|
||||
|
||||
# Gated behind the `whisper` feature (see [features] above).
|
||||
whisper-rs = { version = "0.16", default-features = false, features = ["vulkan"], optional = true }
|
||||
# Gated behind the `whisper` feature (see [features] above). Vulkan is
|
||||
# additive via the `whisper-vulkan` feature so non-GPU targets can drop it.
|
||||
whisper-rs = { version = "0.16", default-features = false, optional = true }
|
||||
|
||||
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing.
|
||||
# Gated alongside whisper-rs since no other code in this crate needs it.
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::model_registry::{find_model, ModelFile};
|
||||
use kon_core::types::{DownloadProgress, ModelId};
|
||||
|
||||
static ACTIVE_DOWNLOADS: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
struct DownloadReservation {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl DownloadReservation {
|
||||
fn acquire(id: &ModelId) -> Result<Self> {
|
||||
let id = id.as_str().to_string();
|
||||
let mut active = ACTIVE_DOWNLOADS
|
||||
.lock()
|
||||
.map_err(|_| KonError::DownloadFailed("download lock poisoned".into()))?;
|
||||
if !active.insert(id.clone()) {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"download already in progress for {id}"
|
||||
)));
|
||||
}
|
||||
Ok(Self { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DownloadReservation {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut active) = ACTIVE_DOWNLOADS.lock() {
|
||||
active.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the models storage directory.
|
||||
/// Windows: %LOCALAPPDATA%/kon/models
|
||||
/// Unix: ~/.kon/models
|
||||
pub fn models_dir() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon").join("models")
|
||||
} else {
|
||||
dirs_path().join("models")
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_path() -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(local_app_data).join("kon")
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".kon")
|
||||
}
|
||||
kon_core::paths::app_paths().models_dir()
|
||||
}
|
||||
|
||||
/// Get the directory path where a specific model's files are stored.
|
||||
pub fn model_dir(id: &ModelId) -> PathBuf {
|
||||
models_dir().join(id.as_str())
|
||||
kon_core::paths::app_paths().speech_model_dir(id)
|
||||
}
|
||||
|
||||
/// Check whether all files for a model have been downloaded.
|
||||
@@ -39,6 +56,7 @@ pub fn is_downloaded(id: &ModelId) -> bool {
|
||||
};
|
||||
let dir = model_dir(id);
|
||||
entry.files.iter().all(|f| dir.join(f.filename).exists())
|
||||
&& verified_manifest_matches(entry, &dir)
|
||||
}
|
||||
|
||||
/// List all downloaded model IDs.
|
||||
@@ -61,6 +79,7 @@ pub async fn download(
|
||||
id: &ModelId,
|
||||
progress: impl Fn(DownloadProgress) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let entry = find_model(id).ok_or_else(|| KonError::ModelNotFound(id.clone()))?;
|
||||
|
||||
let dir = model_dir(id);
|
||||
@@ -69,36 +88,70 @@ pub async fn download(
|
||||
for file in &entry.files {
|
||||
let dest = dir.join(file.filename);
|
||||
if dest.exists() {
|
||||
if let Some(expected_sha) = file.sha256 {
|
||||
// Validate the existing file. If the hash doesn't match,
|
||||
// the file is corrupt (partial download, tampering, bit
|
||||
// rot) and we must re-fetch it to avoid crashing on
|
||||
// model load later.
|
||||
match sha256_of_file(&dest) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(expected_sha) => continue,
|
||||
Ok(_actual) => {
|
||||
// Corrupt — remove + fall through to re-download.
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"failed to verify existing {}: {e}",
|
||||
file.filename
|
||||
)));
|
||||
}
|
||||
// Validate the existing file. If the hash doesn't match,
|
||||
// the file is corrupt (partial download, tampering, bit
|
||||
// rot) and we must re-fetch it to avoid crashing on
|
||||
// model load later.
|
||||
match sha256_of_file(&dest) {
|
||||
Ok(actual) if actual.eq_ignore_ascii_case(file.sha256) => continue,
|
||||
Ok(_actual) => {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"failed to verify existing {}: {e}",
|
||||
file.filename
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
// No checksum — honour the existing file as-is; the
|
||||
// engine will barf on load if it's broken.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
download_file(file, &dest, id, &progress).await?;
|
||||
}
|
||||
|
||||
write_verified_manifest(entry, &dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verified_manifest_path(dir: &Path) -> PathBuf {
|
||||
dir.join(".kon-verified")
|
||||
}
|
||||
|
||||
fn verified_manifest_matches(entry: &kon_core::model_registry::ModelEntry, dir: &Path) -> bool {
|
||||
let manifest = match std::fs::read_to_string(verified_manifest_path(dir)) {
|
||||
Ok(contents) => contents,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
for file in &entry.files {
|
||||
let path = dir.join(file.filename);
|
||||
let size = match std::fs::metadata(&path) {
|
||||
Ok(metadata) => metadata.len(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
let expected_line = format!("{}\t{}\t{}", file.filename, file.sha256, size);
|
||||
if !manifest.lines().any(|line| line == expected_line) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn write_verified_manifest(
|
||||
entry: &kon_core::model_registry::ModelEntry,
|
||||
dir: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let mut lines = Vec::with_capacity(entry.files.len() + 1);
|
||||
lines.push("version\t1".to_string());
|
||||
for file in &entry.files {
|
||||
let size = std::fs::metadata(dir.join(file.filename))?.len();
|
||||
lines.push(format!("{}\t{}\t{}", file.filename, file.sha256, size));
|
||||
}
|
||||
std::fs::write(
|
||||
verified_manifest_path(dir),
|
||||
format!("{}\n", lines.join("\n")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Non-streaming SHA256 of a file on disk. Used by `download()` to
|
||||
/// validate an existing complete file before trusting it.
|
||||
fn sha256_of_file(path: &Path) -> std::io::Result<String> {
|
||||
@@ -151,9 +204,7 @@ async fn download_file(
|
||||
|
||||
let mut request = client.get(file.url);
|
||||
|
||||
// If we have a partial file and no SHA256 to verify (can't verify partial),
|
||||
// request a range resume. If SHA256 is set, we restart to ensure integrity.
|
||||
let resuming = existing_bytes > 0 && file.sha256.is_none();
|
||||
let resuming = existing_bytes > 0;
|
||||
if resuming {
|
||||
request = request.header("Range", format!("bytes={existing_bytes}-"));
|
||||
}
|
||||
@@ -223,19 +274,23 @@ async fn download_file(
|
||||
std::fs::File::create(&part_path)?
|
||||
};
|
||||
|
||||
// Incremental SHA256 — only when a checksum is provided
|
||||
let mut hasher = file.sha256.map(|_| Sha256::new());
|
||||
|
||||
// If resuming without SHA256, we can't hash the already-downloaded portion,
|
||||
// but we also don't need to — we only hash when sha256 is set, and we
|
||||
// restart from scratch in that case.
|
||||
let mut hasher = Sha256::new();
|
||||
if actually_resuming {
|
||||
let mut partial = std::fs::File::open(&part_path)?;
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
let n = std::io::Read::read(&mut partial, &mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut out, &chunk)?;
|
||||
if let Some(ref mut h) = hasher {
|
||||
h.update(&chunk);
|
||||
}
|
||||
hasher.update(&chunk);
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let percent = if total_bytes > 0 {
|
||||
@@ -258,17 +313,13 @@ async fn download_file(
|
||||
|
||||
drop(out);
|
||||
|
||||
// Verify SHA256 if provided
|
||||
if let (Some(expected), Some(hasher)) = (file.sha256, hasher) {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != expected {
|
||||
// Delete corrupt file so next attempt starts fresh
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, expected, actual
|
||||
)));
|
||||
}
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != file.sha256 {
|
||||
let _ = std::fs::remove_file(&part_path);
|
||||
return Err(KonError::DownloadFailed(format!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
file.filename, file.sha256, actual
|
||||
)));
|
||||
}
|
||||
|
||||
// Atomic rename — file is complete and verified
|
||||
@@ -428,7 +479,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None, // resume path only kicks in when sha256 is absent
|
||||
sha256: leak(expected_sha.clone()),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -437,9 +488,6 @@ mod tests {
|
||||
let bytes = std::fs::read(&dest).unwrap();
|
||||
assert_eq!(bytes, body);
|
||||
assert!(!part.exists());
|
||||
// Confirm the full file hash matches what we would have got via
|
||||
// a clean download — gives the resume path indirect integrity
|
||||
// coverage even when the ModelFile has no sha256 set.
|
||||
assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha);
|
||||
}
|
||||
|
||||
@@ -451,6 +499,7 @@ mod tests {
|
||||
// partial bytes and write the fresh body from offset zero rather
|
||||
// than appending on top.
|
||||
let body = b"fresh transcription payload that replaces any stale partial".to_vec();
|
||||
let expected_sha = format!("{:x}", sha2::Sha256::digest(&body));
|
||||
let addr = spawn_no_range_server(body.clone()).await;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -465,7 +514,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None,
|
||||
sha256: leak(expected_sha),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -520,7 +569,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: None,
|
||||
sha256: leak("0".repeat(64)),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
@@ -548,7 +597,7 @@ mod tests {
|
||||
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
|
||||
url: leak(format!("http://{addr}/fixture.bin")),
|
||||
size: kon_core::types::Megabytes(0),
|
||||
sha256: Some(leak("deadbeef".repeat(8))),
|
||||
sha256: leak("deadbeef".repeat(8)),
|
||||
};
|
||||
let id = ModelId::new("test-fixture");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -318,17 +318,25 @@ impl VadChunker for RmsVadChunker {
|
||||
// whatever is still open as the closing chunk.
|
||||
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
|
||||
emitted.push(self.emit_active_chunk_and_close());
|
||||
} else if self.state == State::InSpeech {
|
||||
// hit_max emitted mid-flush and left state in InSpeech
|
||||
// with active_chunk empty. Reset cleanly without emitting
|
||||
// a zero-length closing chunk — the hit_max chunk already
|
||||
// carried all the audio.
|
||||
self.state = State::Idle;
|
||||
self.silent_tail_samples = 0;
|
||||
self.pending_onset_frames = 0;
|
||||
self.onset_buffer.clear();
|
||||
}
|
||||
|
||||
// Defence in depth: every flush exit-path must leave the chunker
|
||||
// in the same clean state a freshly-constructed one is in,
|
||||
// bar `next_sample_index` (the running total-samples counter,
|
||||
// intentionally preserved across flush). Without this, a flush
|
||||
// that emitted via `consume_frame`'s hit_max branch could leave
|
||||
// `state == InSpeech` with stale `silent_tail_samples` or a
|
||||
// populated `onset_buffer`, so the next feed() bleeds prior-
|
||||
// session state into the first chunk of a fresh recording.
|
||||
// The earlier branches already did most of this; the explicit
|
||||
// clear here is a single source of truth.
|
||||
self.state = State::Idle;
|
||||
self.pending.clear();
|
||||
self.active_chunk.clear();
|
||||
self.silent_tail_samples = 0;
|
||||
self.pending_onset_frames = 0;
|
||||
self.onset_buffer.clear();
|
||||
|
||||
emitted
|
||||
}
|
||||
|
||||
@@ -683,4 +691,45 @@ mod tests {
|
||||
"start_sample must not skip past the onset frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_is_idempotent_and_leaves_clean_state() {
|
||||
// Drive the chunker through a full speech-then-silence cycle so
|
||||
// most of the state-machine fields are exercised, flush once,
|
||||
// then assert that flushing again is a no-op AND that feed-with-
|
||||
// silence emits nothing (i.e. no stale onset / silent_tail
|
||||
// bookkeeping leaks into the next feed).
|
||||
let mut c = RmsVadChunker::with_thresholds(
|
||||
0.01,
|
||||
0.005,
|
||||
DEFAULT_SPEECH_ONSET_FRAMES,
|
||||
FRAME_SAMPLES * 4,
|
||||
FRAME_SAMPLES * 50,
|
||||
);
|
||||
|
||||
let speech = constant_signal(FRAME_SAMPLES * 6, 0.02);
|
||||
let _ = c.push(&speech);
|
||||
// Force a partial pending tail so flush exercises the padded-
|
||||
// final-frame branch.
|
||||
let partial = constant_signal(FRAME_SAMPLES / 3, 0.02);
|
||||
let _ = c.push(&partial);
|
||||
|
||||
let _first = c.flush();
|
||||
|
||||
let second = c.flush();
|
||||
assert!(
|
||||
second.is_empty(),
|
||||
"second flush must be a no-op; got {} chunk(s)",
|
||||
second.len()
|
||||
);
|
||||
|
||||
// A subsequent silent feed must emit nothing — proves nothing
|
||||
// about prior speech leaked into the new session's bookkeeping.
|
||||
let silence = constant_signal(FRAME_SAMPLES * 4, 0.0);
|
||||
let chunks = c.push(&silence);
|
||||
assert!(
|
||||
chunks.is_empty(),
|
||||
"post-flush silence must not emit any chunk; got {chunks:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
353
docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md
Normal file
353
docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
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 3–8 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.5–2.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 1–3 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) — **REVISED 2026/04/23**
|
||||
|
||||
**Why here.** Big differentiator. Scheduled late because it nudges *about* the things the earlier phases built (tasks, timers, rituals), and needs careful copy so it doesn't collapse into a push-notification daemon. Spec is explicit: not push notifications — anticipatory guidance.
|
||||
|
||||
**Revised architecture — "nudge bus" hybrid.** Earlier drafts proposed a Rust-side OS-activity watcher (keyboard, active window). Cross-platform review flagged this as fragile: Wayland offers no sanctioned global-keyboard API, macOS needs accessibility permission, Windows needs a message-loop hook, and the signal is low quality everywhere. Deferred to post-v0.1.
|
||||
|
||||
Phase 6 instead ships a **frontend-owned nudge bus** that consumes signals Corbie already produces:
|
||||
- Focus-timer state (running / completed / cancelled) — already on `window` events from Phase 1.
|
||||
- Task-completed events — adds a `kon:task-completed` window event dispatched when `complete_task_cmd` / `complete_subtask_cmd` resolve.
|
||||
- Micro-step generation — event from the decompose path.
|
||||
- Ritual state — `ritualsMorning`, `lastTriageDate` from settings/storage.
|
||||
- App focus / visibility — `document.visibilitychange` + Tauri window `focus`/`blur` events.
|
||||
|
||||
The bus applies suppression rules, then dispatches via Rust commands for platform-native delivery.
|
||||
|
||||
**Notification plugin prerequisites (new cross-cutting work, done in Phase 6).**
|
||||
- Add `tauri-plugin-notification = "2"` (Cargo.toml) + `@tauri-apps/plugin-notification` (package.json).
|
||||
- Register the plugin in `lib.rs`.
|
||||
- Frontend checks `isPermissionGranted()` and calls `requestPermission()` on first use.
|
||||
- Expose ACL entries in `src-tauri/capabilities/default.json`.
|
||||
- Windows caveat: notifications only deliver properly for *installed* apps (not `tauri dev` builds). Flag this in release notes and the dev HANDOVER.
|
||||
- Sound path: Windows expects a `.wav` file path, not a platform sound name. Drop the original spec's `"Default"` string for Windows; ship a tiny custom `.wav` in `src-tauri/sounds/`. macOS `"Glass"` is valid. Linux freedesktop sound-name `message-new-instant` works via `tauri-plugin-notification`'s `sound` option.
|
||||
|
||||
**Scope (revised).**
|
||||
- `nudgeBus.svelte.ts` store — subscribes to the in-app signals above, owns cooldown/suppression logic.
|
||||
- Rust command `deliver_nudge(title, body, sound?)` — thin wrapper around `tauri-plugin-notification` that also persists a row to a new `nudges` SQLite table (for debugging + future analytics).
|
||||
- **Trigger set for v1 (all in-app, no OS-wide detection):**
|
||||
- `inactivity_with_active_timer` — timer running + `document.visibilitystate === 'hidden'` OR window `blur` for 90 s continuous. (We know the user has switched away; we don't need to know what they switched to.)
|
||||
- `pending_morning_triage` — past 10:00 local + triage enabled + last-shown ≠ today. Fires once per day; gets suppressed forever if user later skips or completes.
|
||||
- `micro_step_idle` — micro-step created + no `kon:task-completed` or `kon:step-completed` event for that parent-task-id within 15 min.
|
||||
- **Suppression rules:**
|
||||
- Global mute in settings (on/off).
|
||||
- Hard cap 3 nudges per rolling hour.
|
||||
- No nudge in first 60 s of a timer.
|
||||
- No nudge during app focus (the user is already looking).
|
||||
- Rhythmic voice anchoring: piggy-back on Phase 4 TTS. Optional "speak nudges aloud" toggle. Default off. British-English calm lines: "Time to move on", "Your list is still here". No personality yet — Phase 9.
|
||||
|
||||
**Out of scope.**
|
||||
- OS-wide activity detection (keyboard hooks, active-window polling). Deferred post-v0.1 as a separate phase, if a real need emerges.
|
||||
- Custom trigger editor (owned by Phase 7).
|
||||
- Biometric signals. Any Margot-as-character visual.
|
||||
|
||||
**Acceptance.** Each trigger fires on its defined condition in a dogfood walkthrough. Suppression observed (hidden-for-90 s → nudge; return-to-focus → no nudge). Global mute kills everything immediately. Notification permission request appears on first trigger; denial is respected.
|
||||
|
||||
**Estimated effort.** 1 – 2 days (including notification-plugin setup and the nudges table).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Implementation intentions (if-then automation) — **REVISED 2026/04/23**
|
||||
|
||||
**Why here.** Leans on Phase 6's nudge bus. User-defined rules reuse the same delivery path.
|
||||
|
||||
**Rule idempotency (new explicit requirement).**
|
||||
- Each rule stores `last_fired_at: ISO8601` and, for daily rules, `last_fired_local_date: YYYY-MM-DD`. Without this, a poll-driven "at 09:00" fires on every tick.
|
||||
- On sleep/resume: on app focus after > 10 minutes away, check each time-of-day rule; if today's fire time has passed and `last_fired_local_date` is not today, fire once and update. Configurable per-rule toggle: `catch_up_on_resume` (default ON for time-of-day rules).
|
||||
- "After a task completes" rule: subscribes to the `kon:task-completed` event from Phase 6. Rule fires once per task id (guarded via `last_fired_task_ids`).
|
||||
- "Morning triage finishes" rule: fires on *either* "Start the day" or "Skip for today" — skip counts as finishing. Fires once per `last_fired_local_date`.
|
||||
|
||||
**Scope.**
|
||||
- Rule editor UI. Minimal: `if [when-condition], then [action]`.
|
||||
- When-conditions for v1: `time of day = HH:MM`, `after a task completes` (pick a specific task from list), `morning triage finishes`.
|
||||
- Actions for v1: `surface a specific task` (jump to Tasks page + highlight), `start a 5-min timer`, `speak a line aloud` (reuses Phase 4 TTS).
|
||||
- Rules stored in SQLite (`rules` table: id, name, when_json, then_json, enabled, last_fired_at, last_fired_local_date, last_fired_task_ids). Global mute respected.
|
||||
- No location triggers (desktop app, no geolocation). No app-running detection (fragile cross-platform — revisit post-v0.1).
|
||||
|
||||
**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' aloud and surface my 'daily standup' task", save it, have it fire next morning at 09:00. Polling tick does not re-fire it. Delete works. Sleep the machine through 09:00; on resume the rule catches up once, then goes quiet until tomorrow.
|
||||
|
||||
**Estimated effort.** 1 – 1.5 days.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Forgiving gamification — **REVISED 2026/04/23** — **SHIPPED 2026/04/24**
|
||||
|
||||
**Why here.** Low-risk, closes the spec list.
|
||||
|
||||
**Scope (revised — grace days dropped).**
|
||||
- Completion count per day (non-punitive). "You've finished 3 today."
|
||||
- **No streaks, no chains** — so the original "grace days" logic was solving a problem that doesn't exist in this design. Dropped.
|
||||
- Optional "recent momentum" sparkline: last 7 days' daily completion counts as a tiny inline chart on the Tasks header. Always additive; empty days render as baseline, never as gaps.
|
||||
- Visual: soft-edged numeric badges on the Tasks header. No leaderboards, no social comparison.
|
||||
- Zero loss language. Always "look what you did".
|
||||
|
||||
**Out of scope.** Leaderboards. Shared challenges. Streak repair purchases. XP systems.
|
||||
|
||||
**Acceptance.** Complete 3 tasks today, header shows "3 today". Open the app after 4 days off, no "you were away" framing; header reads today's count only; sparkline simply shows flat zero bars for the away days.
|
||||
|
||||
**Estimated effort.** Half day.
|
||||
|
||||
**Shipped note (2026/04/24).** Landed on `main` across commits `729b82c` to `fa93033` (13 feature commits plus one style-fix commit for a comment em-dash). Migration v13 adds `auto_completed` on `tasks`; cascade path in `complete_subtask_and_check_parent` sets it, `uncomplete_task` clears it on both target and reopened parent. New storage fn `list_recent_completions(pool, days)` + Tauri wrapper `list_recent_completions_cmd` expose the fixed-length, oldest-first 7-day series. Frontend has a dedicated `completionStats.svelte.ts` store (listens on `kon:task-completed` / `kon:step-completed` / `kon:task-uncompleted` / `kon:task-deleted` / `focus`), a `CompletionSparkline.svelte` SVG component, and header wiring in `TasksPage.svelte`. Settings toggle `showMomentumSparkline` added (default `true`) in the Rituals section; Phase 9 polish may resection. Acceptance list verified by full suite: 273 Rust tests pass, `cargo clippy --all-targets -D warnings` clean, `cargo fmt --check` clean, `npm run check` 0/0, `npm run build` clean. Manual dogfood walkthrough (Task 12 Step 6) still owes real-app verification when Jake next opens Corbie.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Polish debt — **MOSTLY SHIPPED 2026/04/24-25**
|
||||
|
||||
> **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.
|
||||
|
||||
**Shipped note (2026/04/25).** Sub-phases 9a (export plumbing) + 9b
|
||||
(LLM content tags + migration v14 + storage extension) + sparkline
|
||||
motion / a11y polish all on `main`, commits `49a795f` to `dd45f10`.
|
||||
Migration v14 adds `transcripts.llm_tags`; `update_transcript_meta`
|
||||
gains a sixth Option; the latent `manualTags` persistence bug was
|
||||
also fixed in passing (the pre-existing `saveHistory()` no-op stub
|
||||
is now bypassed by HistoryPage tag handlers calling
|
||||
`saveTranscriptMeta`). Suite green: 277 cargo tests / clippy clean
|
||||
all-targets / fmt clean / svelte-check 0/0 / npm build clean.
|
||||
|
||||
**Deferred to Phase 9 follow-up (post-v0.1 polish iteration):**
|
||||
- Full `SettingsPage` regroup into 7 progressive-disclosure groups
|
||||
(Start here / Transcription / Tasks / Rituals / Notifications /
|
||||
Accessibility / Advanced) plus search box. The 2309-line file
|
||||
uses a hand-rolled accordion that needs careful unwinding; only
|
||||
the Phase 8 carryover sparkline relocation landed this session.
|
||||
`SettingsGroup.svelte` component is in tree, ready for that pass.
|
||||
- Walkthrough-driven a11y / contrast / typography sweeps. The
|
||||
scoped checklist (keyboard traversal, focus-visible ring sweep,
|
||||
WCAG AA contrast in both themes, dark-mode parity, prefers-
|
||||
reduced-motion checks) needs a running dev server to validate.
|
||||
Phase 10a QC absorbs the walkthrough.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — QC + rename + release — **SPLIT 2026/04/23**
|
||||
|
||||
Earlier draft rolled QC, a full codebase rename, an app-data migration shim, and the release ceremony into one day. Review flagged this as unrealistic and split it. The rename sweep in particular crosses every surface in the app; rushing it is how you end up with `kon.db` on half the users' machines and `corbie.db` on the rest.
|
||||
|
||||
### Pre-Phase-10: Cargo.lock policy decision — **RESOLVED 2026/04/24**
|
||||
|
||||
- `.gitignore` previously excluded `Cargo.lock`. For a Tauri binary workspace this was the wrong default, since CI resolves dependencies fresh each run (the leading theory for the 2026-04-24 CI red-state noted in earlier `HANDOVER.md` revisions).
|
||||
- **Resolution (Jake's hardening pass, commit `b333c62`):** `Cargo.lock` now committed. Captures the dep set users actually get from release artefacts rather than whatever crates.io happened to resolve at build time.
|
||||
- No further action before v0.1.0 tagging.
|
||||
|
||||
### Phase 10a — QC (estimated half day)
|
||||
|
||||
**Prerequisite:** Phase 1 – Phase 9 complete. Cargo.lock committed.
|
||||
|
||||
- Full dogfood walkthrough: record a real brain-dump → clean transcript → task extraction → micro-step one task → run a focus timer → tag energy → complete → open evening wind-down → skip morning triage → re-check no re-prompt.
|
||||
- RB-08 macOS power-assertion verification: **Rachmann runs this offline** on his Mac. `pmset -g assertions` during a live session; expected `PreventSystemSleep` attributed to Corbie's bundle id. On confirmation, close RB-08 and move `docs/issues/power-assertion-macos-objc2.md` to `docs/issues/resolved/`.
|
||||
- Cross-platform build matrix green across Linux / macOS / Windows.
|
||||
- Accessibility regression check: keyboard-only traversal of every new Phase 5 – Phase 8 surface.
|
||||
- Freshly-clean install test on a spare user account: no stray data leaks from dev.
|
||||
|
||||
Rachmann's Mac slot runs in parallel; not blocking the rest of 10a.
|
||||
|
||||
### Phase 10b — Kon → Corbie rename sweep (estimated half day to 1 day)
|
||||
|
||||
Runs **after** Phase 10a QC, **after** Jake has renamed the two repos in GitHub + Gitea web UIs. The rename only starts here, not earlier, so in-flight Phase 1 – Phase 9 work doesn't have to re-learn event names mid-cycle.
|
||||
|
||||
- `package.json` → `name: "corbie"`, `description` update. (Version stays at `0.1.0`.)
|
||||
- Cargo crates: `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` name field + workspace path references + `use` imports.
|
||||
- Binary + product names: `src-tauri/tauri.conf.json` (productName, identifier), `.desktop` file, Windows product name, macOS bundle name.
|
||||
- Install paths: `~/.local/share/kon/` → `~/.local/share/corbie/` (plus the macOS `~/Library/Application Support` and Windows `%APPDATA%` equivalents). **App-data migration shim required** — first-run checks for the old dir, moves contents, writes a sentinel `.migrated-from-kon`. Shim must handle the case where both dirs exist (prefer new, log the duplicate).
|
||||
- Database filename: `kon.db` → `corbie.db`. Handled by the same shim.
|
||||
- Window titles, tray tooltip, About-dialog, README body, docs references where they name the product (leave historical brief content talking about "Kon" — it's a historical document).
|
||||
- Event names: `kon:start-timer`, `kon:task-completed`, `kon:open-wind-down`, `kon:preferences-changed`, `kon:hotkey-pressed`, `kon:llm-download-progress` → `corbie:*`. Single commit; one find-replace; both emitter and listener in the same diff.
|
||||
- Logs, error messages, user-facing copy (including toast strings that mention "Kon").
|
||||
- Settings SQLite key: `kon_preferences` → `corbie_preferences`. Migration reads old key on first launch, writes new key, deletes old.
|
||||
- Remotes: `ssh://git.corbel.consulting:2222/jake/kon.git` + `github.com:jakejars/kon.git` → `…/corbie.git`. `git remote set-url` locally after web-UI renames.
|
||||
|
||||
### Phase 10c — Release (estimated half day)
|
||||
|
||||
- Version is already `0.1.0` in `Cargo.toml`, `package.json`, and `tauri.conf.json` — no bump needed. Confirm the three match.
|
||||
- Write `CHANGELOG.md`. Seed from this roadmap's phases. Entries are written to end-users, not engineers — "You can now read transcripts aloud" not "Added tts_speak command".
|
||||
- Write release notes in plain language: what it does, who it's for, the Kon-data migration note, the Windows notifications caveat (installed app only).
|
||||
- Tag `v0.1.0` on the head commit.
|
||||
- Push tag to both remotes. GitHub Actions release workflow auto-builds artefacts for Linux / macOS / Windows.
|
||||
- Smoke-test at least one artefact per platform (ideally Rachmann covers macOS) before the release is made public.
|
||||
|
||||
**Estimated effort (Phase 10 total).** 1 – 2 days across 10a / 10b / 10c plus Rachmann's parallel Mac session.
|
||||
|
||||
---
|
||||
|
||||
## Totals
|
||||
|
||||
- Phase 1 – 8 feature build: **6 – 9 days** of focused work
|
||||
- Phase 9 polish: **1 – 2 days**
|
||||
- Phase 10 QC + rename + release (split): **1 – 2 days** + Rachmann's Mac session
|
||||
|
||||
**Total to v0.1.0 feature-complete release:** **~8 – 13 days of focused work**, depending on how much polish time Jake wants in Phase 9. Revised downward at the lower end after the Phase 8 grace-day drop and Phase 10 split clarified actual scope.
|
||||
|
||||
## 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).
|
||||
|
||||
## Post-v0.1 ideas (captured, not scheduled)
|
||||
|
||||
Ideas worth keeping warm once the v0.1 release is out. Not tracked as phases until the core release is done.
|
||||
|
||||
- **Calendar integration.** Read-only first pass could parse a local ICS file (Thunderbird / Evolution export) and surface day events alongside the tasks list — stays local-first. A cloud-sync pass (Google / iCloud / CalDAV) is a v0.2+ conversation because it re-opens credential handling and refresh-token plumbing that v0.1 deliberately avoids.
|
||||
- **Right-click highlighted text → capture as task.** Two flavours: (a) *In-Corbie* — context menu on a selected transcript range or viewer segment, routes to `create_task_cmd` with the selection as text. Small — lives naturally in Phase 9 polish or a bolt-on. (b) *System-wide* — highlight anywhere (browser, Slack, IDE) and call Corbie. Platform-painful: macOS Services API, Windows shell-extension, Linux desktop-env-specific context menus. Scope as a separate post-v0.1 phase.
|
||||
|
||||
## 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.*
|
||||
BIN
docs/roadmap/phase1-focus-timer-screenshot.png
Normal file
BIN
docs/roadmap/phase1-focus-timer-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
261
docs/superpowers/audits/2026-04-25-phase10a-static-slice.md
Normal file
261
docs/superpowers/audits/2026-04-25-phase10a-static-slice.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
name: Phase 10a static slice audit
|
||||
description: Static a11y + contrast + CI verification produced by Wren on 2026/04/25 as the agent-runnable portion of Phase 10a, ahead of Jake's manual walkthrough and feedback-document pass.
|
||||
type: audit
|
||||
tags: [phase10a, audit, a11y, contrast, ci, release]
|
||||
created: 2026/04/25
|
||||
status: findings
|
||||
author: Wren (CORBEL's resident agent) on behalf of Jake Sames
|
||||
---
|
||||
|
||||
# Phase 10a — Static Slice Audit
|
||||
|
||||
> **Scope.** Everything in Phase 10a that an agent can verify without
|
||||
> a running dev server. The dogfood walkthrough, RB-08 macOS power-
|
||||
> assertion verification, and runtime keyboard / screen-reader
|
||||
> traversal stay with Jake (or Rachmann for the Mac slot).
|
||||
>
|
||||
> **Baseline.** `main` at `0ca4e0e`. 277 cargo tests pass. clippy /
|
||||
> fmt / svelte-check / npm build all clean.
|
||||
|
||||
## Summary of findings
|
||||
|
||||
- **A11y static rules: clean.** svelte-check reports 0/0 across 3957
|
||||
files. Spot-checks confirm consistent `aria-label` on icon-only
|
||||
buttons and `aria-hidden="true"` on inner SVGs. Global
|
||||
`:focus-visible` ring is set in design tokens.
|
||||
- **Contrast: real fails in light theme + small dim text in both
|
||||
themes.** Nine token pairs miss WCAG AA-normal (4.5:1). One pair
|
||||
(`warning` on `bg`, light) misses AA-large too.
|
||||
- **CI: cross-platform `cargo check` matrix exists and runs on every
|
||||
push to `main`.** The full Tauri installer build (`build.yml`) has
|
||||
never been exercised end-to-end and should be triggered by manual
|
||||
workflow_dispatch before tagging v0.1.0.
|
||||
|
||||
## A11y baseline
|
||||
|
||||
- `npm run check` (svelte-check 4): 0 errors / 0 warnings on 3957
|
||||
files. Svelte's a11y ruleset enforces `<img>` alt text, form-input
|
||||
labels, click-handler-on-non-interactive, autofocus restrictions,
|
||||
noninteractive-tabindex, and roughly twenty other rules. All pass.
|
||||
- Spot-checked icon-only buttons across `Sidebar.svelte`,
|
||||
`ToastViewport.svelte`, `MicroSteps.svelte`, `HotkeyRecorder.svelte`,
|
||||
`HistoryPage.svelte`, `TasksPage.svelte`. Every button carries
|
||||
`aria-label`. Every inner `<svg>` carries `aria-hidden="true"`. The
|
||||
pattern is consistent enough to assume it's the convention rather
|
||||
than coincidence.
|
||||
- Global focus ring at `src/design-system/colors_and_type.css:225`:
|
||||
`:focus-visible { outline: 2px solid var(--accent); outline-offset:
|
||||
3px; border-radius: var(--radius-md); }`. Covers every focusable
|
||||
element by default.
|
||||
- `prefers-reduced-motion` guards present in 8 files
|
||||
(`ToastViewport`, `CompletionSparkline`, `SettingsGroup`,
|
||||
`DictationPage`, `TasksPage`, `app.css`, `colors_and_type.css`,
|
||||
plus the `preferences.svelte.ts` store that exposes the flag).
|
||||
These cover the four scaled / staggered animations Phase 8 + 9
|
||||
introduced. Hover-only colour transitions across the rest of the
|
||||
app rely on the global `*` transition rule and don't pose a
|
||||
vestibular risk; they're under the reduced-motion threshold.
|
||||
|
||||
## Contrast audit (WCAG 2.1 AA)
|
||||
|
||||
Computed via Python (sRGB → linear-RGB → relative luminance →
|
||||
Web Content Accessibility Guidelines contrast ratio). Floors:
|
||||
**4.5:1** for normal text, **3:1** for large text (≥18px regular
|
||||
or ≥14px bold) and UI components.
|
||||
|
||||
### Dark theme
|
||||
|
||||
| Foreground | Background | Ratio | AA-normal | AA-large |
|
||||
|---|---|---:|---|---|
|
||||
| `text` | `bg` | 16.38 | PASS | PASS |
|
||||
| `text` | `bg-elevated` | 15.35 | PASS | PASS |
|
||||
| `text` | `bg-card` | 14.77 | PASS | PASS |
|
||||
| `text` | `sidebar` | 15.90 | PASS | PASS |
|
||||
| `text` | `nav-active` | 14.12 | PASS | PASS |
|
||||
| `text` | `hover` | 14.44 | PASS | PASS |
|
||||
| `text-secondary` | `bg` | 6.39 | PASS | PASS |
|
||||
| `text-secondary` | `bg-elevated` | 5.99 | PASS | PASS |
|
||||
| `text-secondary` | `bg-card` | 5.76 | PASS | PASS |
|
||||
| `text-secondary` | `sidebar` | 6.20 | PASS | PASS |
|
||||
| `text-secondary` | `hover` | 5.63 | PASS | PASS |
|
||||
| `text-tertiary` | `bg` | 3.65 | **FAIL** | PASS |
|
||||
| `text-tertiary` | `bg-elevated` | 3.42 | **FAIL** | PASS |
|
||||
| `text-tertiary` | `bg-card` | 3.29 | **FAIL** | PASS |
|
||||
| `text-tertiary` | `sidebar` | 3.54 | **FAIL** | PASS |
|
||||
| `accent` | `bg` | 9.48 | PASS | PASS |
|
||||
| `accent` | `bg-elevated` | 8.89 | PASS | PASS |
|
||||
| `accent` | `bg-card` | 8.55 | PASS | PASS |
|
||||
| `accent` | `sidebar` | 9.21 | PASS | PASS |
|
||||
| `success` | `bg` | 9.75 | PASS | PASS |
|
||||
| `success` | `bg-card` | 8.80 | PASS | PASS |
|
||||
| `danger` | `bg` | 6.46 | PASS | PASS |
|
||||
| `danger` | `bg-card` | 5.83 | PASS | PASS |
|
||||
| `warning` | `bg` | 11.87 | PASS | PASS |
|
||||
| `warning` | `bg-card` | 10.70 | PASS | PASS |
|
||||
|
||||
### Light theme
|
||||
|
||||
| Foreground | Background | Ratio | AA-normal | AA-large |
|
||||
|---|---|---:|---|---|
|
||||
| `text` | `bg` | 16.70 | PASS | PASS |
|
||||
| `text` | `bg-elevated` | 15.58 | PASS | PASS |
|
||||
| `text` | `bg-card` | 17.70 | PASS | PASS |
|
||||
| `text` | `sidebar` | 15.85 | PASS | PASS |
|
||||
| `text` | `nav-active` | 14.24 | PASS | PASS |
|
||||
| `text` | `hover` | 14.64 | PASS | PASS |
|
||||
| `text-secondary` | `bg` | 6.77 | PASS | PASS |
|
||||
| `text-secondary` | `bg-elevated` | 6.32 | PASS | PASS |
|
||||
| `text-secondary` | `bg-card` | 7.18 | PASS | PASS |
|
||||
| `text-secondary` | `sidebar` | 6.43 | PASS | PASS |
|
||||
| `text-secondary` | `hover` | 5.94 | PASS | PASS |
|
||||
| `text-tertiary` | `bg` | 3.47 | **FAIL** | PASS |
|
||||
| `text-tertiary` | `bg-elevated` | 3.24 | **FAIL** | PASS |
|
||||
| `text-tertiary` | `bg-card` | 3.68 | **FAIL** | PASS |
|
||||
| `text-tertiary` | `sidebar` | 3.30 | **FAIL** | PASS |
|
||||
| `accent` | `bg` | 3.35 | **FAIL** | PASS |
|
||||
| `accent` | `bg-elevated` | 3.12 | **FAIL** | PASS |
|
||||
| `accent` | `bg-card` | 3.55 | **FAIL** | PASS |
|
||||
| `accent` | `sidebar` | 3.18 | **FAIL** | PASS |
|
||||
| `success` | `bg` | 3.98 | **FAIL** | PASS |
|
||||
| `success` | `bg-card` | 4.22 | **FAIL** | PASS |
|
||||
| `danger` | `bg` | 4.39 | **FAIL** | PASS |
|
||||
| `danger` | `bg-card` | 4.65 | PASS | PASS |
|
||||
| `warning` | `bg` | 2.56 | **FAIL** | **FAIL** |
|
||||
| `warning` | `bg-card` | 2.72 | **FAIL** | **FAIL** |
|
||||
|
||||
### Severity ranking
|
||||
|
||||
1. **Light-theme `warning` on every surface** — fails AA-large too
|
||||
(2.56–2.72:1). Anywhere this colour appears against `bg` or
|
||||
`bg-card` is unreadable for low-vision users. Highest priority.
|
||||
2. **Light-theme `accent` as link colour** — global `a { color:
|
||||
var(--accent) }` rule at `colors_and_type.css:220` puts a
|
||||
3.35:1 swatch on body text. Link semantics need AA-normal (4.5).
|
||||
3. **`text-tertiary` in both themes** — used for `.k-caption` (12px),
|
||||
`.k-eyebrow` (10px), `kbd` (11px) and many hint-text contexts
|
||||
(399 class-name hits across the codebase). All small text. All
|
||||
fail AA-normal in both themes.
|
||||
4. **Light-theme `success` on bg / bg-card** — borderline (3.98 /
|
||||
4.22). Fine if used only for icons / chips ≥18px, fails for
|
||||
inline body copy.
|
||||
5. **Light-theme `danger` on bg** — borderline (4.39); 4.65 on
|
||||
bg-card. Fine if used for chips, marginal for inline.
|
||||
|
||||
### Suggested token shifts (estimates, eyeball before committing)
|
||||
|
||||
Numbers below are starting points, not finished colours. Re-run the
|
||||
contrast script against any candidate before merging.
|
||||
|
||||
- `--text-tertiary` (light): `#8a8578` → roughly `#6f6a5c`. Aim
|
||||
≥4.5:1 against `#faf8f5`.
|
||||
- `--text-tertiary` (dark): `#716b60` → roughly `#888278`. Aim
|
||||
≥4.5:1 against `#0f0e0c`.
|
||||
- `--accent` (light): `#b87a4a` → roughly `#9a6535`. Aim ≥4.5:1 for
|
||||
link role; UI / icon use already passes.
|
||||
- `--warning` (light): `#b89a3e` → roughly `#8a7220`.
|
||||
- `--success` (light) and `--danger` (light): nudge by ~5–10% if
|
||||
you find them used as inline text during the walkthrough.
|
||||
|
||||
The Python contrast helper used to generate these tables is small
|
||||
enough to keep inline if useful — it's at the bottom of this file.
|
||||
|
||||
## CI matrix state
|
||||
|
||||
Three workflows in `.github/workflows/`:
|
||||
|
||||
- `check.yml`. Runs `cargo check` on `ubuntu-22.04`,
|
||||
`windows-latest`, `macos-latest` on every push to `main` and every
|
||||
PR. Concurrent-cancels older runs on the same branch. **Should be
|
||||
green at `0ca4e0e`** but cannot be confirmed from this machine
|
||||
(`gh` CLI not installed). Worth eyeballing in the GitHub Actions
|
||||
UI before declaring 10a done.
|
||||
- `build.yml`. Full Tauri installer build (Linux .AppImage + .deb,
|
||||
Windows .msi + .exe, macOS .dmg + .app). Triggers on tag push
|
||||
`v*` or manual workflow_dispatch. **Has never been exercised
|
||||
end-to-end.** First v0.1.0 tag will be its trial run. Strong
|
||||
recommendation: `Run workflow` against `main` once before tagging,
|
||||
to catch matrix-specific failures (Vulkan SDK on Windows,
|
||||
libclang location on Linux 22.04, MoltenVK on macOS) without the
|
||||
release artefact pressure.
|
||||
- `audit.yml`. Weekly cargo + npm audit. Independent of v0.1.
|
||||
|
||||
## Clean-install test plan
|
||||
|
||||
Run on a spare user account or a fresh VM, with no prior Corbie /
|
||||
Kon data. Three iterations: one per platform.
|
||||
|
||||
1. Install the artefact from the platform's `build.yml` output.
|
||||
2. Launch from a clean shell (`corbie` from PATH, or the .app /
|
||||
Start-menu shortcut).
|
||||
3. Verify first-run setup flow renders. Walk through the Whisper /
|
||||
LLM model download for the smallest tier.
|
||||
4. Confirm app data lands at the expected path:
|
||||
- Linux: `~/.local/share/kon/` (will become `~/.local/share/
|
||||
corbie/` after Phase 10b rename).
|
||||
- macOS: `~/Library/Application Support/com.corbel.kon/`.
|
||||
- Windows: `%APPDATA%\com.corbel.kon\`.
|
||||
5. Record a 10-second brain-dump → cleanup → task extraction.
|
||||
Confirm no log leakage to stderr that references `target/` or
|
||||
dev-only paths.
|
||||
6. Quit the app. Open the SQLite db (`kon.db` for now) and verify
|
||||
`SELECT version FROM schema_version ORDER BY version DESC LIMIT
|
||||
1` returns `14`.
|
||||
7. Re-launch. Confirm settings persist, history shows the test
|
||||
transcript with manual + LLM tags.
|
||||
8. Optional but recommended: launch with `RUST_LOG=debug` once and
|
||||
archive the log. Anything referencing `/home/jake/Documents/
|
||||
CORBEL-Projects/kon/target/` is a dev-leak bug.
|
||||
|
||||
For Phase 10c this gets re-run after the rename sweep to confirm
|
||||
the migration shim correctly moves `~/.local/share/kon/` →
|
||||
`~/.local/share/corbie/` and renames `kon.db` → `corbie.db`.
|
||||
|
||||
## Walkthrough checklist (deferred from Phase 9d)
|
||||
|
||||
These need a running dev server, so they belong in Jake's testing
|
||||
session:
|
||||
|
||||
- [ ] Keyboard-only traversal across every page. Tab order respects
|
||||
visual order. No keyboard traps.
|
||||
- [ ] Focus-visible ring shows on every focusable element. Zero
|
||||
invisible focus states.
|
||||
- [ ] WCAG AA contrast verified visually in both themes after any
|
||||
token shifts from the suggestions above.
|
||||
- [ ] Dark-mode parity check: every page renders correctly in
|
||||
light + dark. No hard-coded greys that look fine in one theme
|
||||
and broken in the other.
|
||||
- [ ] Icon-only-button audit: hover reveals the title attribute or
|
||||
the aria-label is announced by VoiceOver / Orca / Narrator.
|
||||
- [ ] `prefers-reduced-motion: reduce` enabled in OS — sparkline
|
||||
stagger, badge entrance, settings-group chevron all stop.
|
||||
- [ ] Screen-reader smoke test: at least announce page titles,
|
||||
primary-button labels, and the sparkline summary line on one
|
||||
platform's SR.
|
||||
|
||||
## Appendix — contrast helper
|
||||
|
||||
Drop into `scripts/contrast.py` or run inline. Pass two hex strings
|
||||
(with or without `#`); prints the AA verdict.
|
||||
|
||||
```python
|
||||
def srgb_to_lin(c):
|
||||
c = c / 255
|
||||
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
def luminance(hex_color):
|
||||
h = hex_color.lstrip('#')
|
||||
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
return 0.2126 * srgb_to_lin(r) + 0.7152 * srgb_to_lin(g) + 0.0722 * srgb_to_lin(b)
|
||||
|
||||
def contrast(c1, c2):
|
||||
l1, l2 = luminance(c1), luminance(c2)
|
||||
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||
```
|
||||
|
||||
## Anchors
|
||||
|
||||
- Roadmap: [docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md](../../roadmap/2026-04-23-corbie-feature-complete-roadmap.md)
|
||||
- Phase 9 spec: [docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md](../specs/2026-04-24-phase9-polish-debt-design.md)
|
||||
- Release-blocker index: [docs/issues/README.md](../../issues/README.md)
|
||||
- Latest handover: [HANDOVER.md](../../../HANDOVER.md)
|
||||
1269
docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md
Normal file
1269
docs/superpowers/plans/2026-04-24-phase8-forgiving-gamification.md
Normal file
File diff suppressed because it is too large
Load Diff
1628
docs/superpowers/plans/2026-04-24-phase9-polish-debt.md
Normal file
1628
docs/superpowers/plans/2026-04-24-phase9-polish-debt.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: Phase 8 — Forgiving gamification (design)
|
||||
description: Design spec for Corbie Phase 8. Surfaces today's completion count and a 7-day momentum sparkline on the Tasks header. No streaks, no grace days, no loss language.
|
||||
type: spec
|
||||
tags: [spec, phase-8, corbie, tasks, gamification]
|
||||
created: 2026/04/24
|
||||
status: approved
|
||||
phase: 8
|
||||
roadmap: docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md
|
||||
author: Wren (CORBEL's resident agent) on behalf of Jake Sames
|
||||
---
|
||||
|
||||
# Phase 8 — Forgiving gamification
|
||||
|
||||
## Summary
|
||||
|
||||
Add two purely additive signals to the Tasks page header:
|
||||
1. **Today's completion count** as a soft-edged badge ("3 today") beside the "Tasks" title.
|
||||
2. **7-day momentum sparkline** — a tiny inline 7-bar chart on the same line.
|
||||
|
||||
No streaks. No chains. No grace days. No loss language. Empty days render as baseline stubs (never gaps). Zero social comparison.
|
||||
|
||||
This is the final feature phase before polish (Phase 9) and release prep (Phase 10).
|
||||
|
||||
## Counting semantics
|
||||
|
||||
A "completion today" is **any task row whose `done_at` falls on the local calendar day and which was completed by explicit user action**. Specifically:
|
||||
|
||||
- Top-level tasks explicitly completed → **count**.
|
||||
- Subtasks explicitly completed (their own checkbox clicked) → **count**.
|
||||
- Parent tasks auto-completed via the `complete_subtask_and_check_parent` cascade → **do not count**. The user already got credit for the subtask they just ticked; the parent closing automatically must not double-count.
|
||||
- Uncompleting a task removes it from the count immediately (its row stops matching the query because `done_at` is nulled).
|
||||
|
||||
Day boundary is **local-time midnight-to-midnight**. `done_at` is stored as UTC; conversion uses SQLite's `DATE(done_at, 'localtime')`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data-model change — migration v13
|
||||
|
||||
Add a column to distinguish cascade-completed parents from explicit completions:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tasks ADD COLUMN auto_completed INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX idx_tasks_done_at_auto_completed
|
||||
ON tasks(done_at, auto_completed)
|
||||
WHERE done_at IS NOT NULL;
|
||||
```
|
||||
|
||||
Partial index on `done_at IS NOT NULL` keeps the index small — only completed rows occupy it. Forward-only migration: existing completed rows default to `auto_completed = 0` (they will count). We have no way to retro-detect pre-migration cascades; accept this one-time inaccuracy.
|
||||
|
||||
### Rust changes
|
||||
|
||||
**`crates/storage/src/database.rs`:**
|
||||
- `complete_subtask_and_check_parent`: when the cascade UPDATE closes the parent, set `auto_completed = 1` on that UPDATE. The subtask UPDATE itself leaves `auto_completed` at 0.
|
||||
- `complete_task`: unchanged — leaves `auto_completed` at 0.
|
||||
- `uncomplete_task`: the UPDATE already clears `done = 0, done_at = NULL`; also clear `auto_completed = 0` to keep the row clean if completed again via a different path.
|
||||
- New function `list_recent_completions(pool, days)` returning `Vec<DailyCompletionCount>`:
|
||||
|
||||
```rust
|
||||
pub struct DailyCompletionCount {
|
||||
pub day: String, // "YYYY-MM-DD" local
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
pub async fn list_recent_completions(
|
||||
pool: &SqlitePool,
|
||||
days: u32,
|
||||
) -> Result<Vec<DailyCompletionCount>>;
|
||||
```
|
||||
|
||||
Query groups matching rows by local-day and then left-joins against a generated N-day spine (computed in Rust) so empty days are explicit zeros, not missing entries. Result is ordered oldest → newest.
|
||||
|
||||
**Tauri command:** `list_recent_completions_cmd(days: u32)` in `src-tauri`, wired via the standard invoke pattern used by existing task commands.
|
||||
|
||||
### Frontend changes
|
||||
|
||||
**New store `src/lib/stores/completionStats.svelte.ts`:**
|
||||
|
||||
```typescript
|
||||
// Exposes:
|
||||
// recentCompletions: DailyCompletionCount[] — always length = days
|
||||
// todayCount: number — derived from last entry
|
||||
// refresh(): Promise<void> — invokes the Rust command
|
||||
|
||||
// Refreshes on:
|
||||
// - initial load (called from the same init path that runs loadTasks)
|
||||
// - after every task-mutation helper in page.svelte.ts
|
||||
// (completeTask / uncompleteTask / deleteTask / completeSubtask /
|
||||
// uncompleteSubtask / deleteSubtask) — call sites add a
|
||||
// completionStats.refresh() alongside the existing loadTasks() refresh
|
||||
// - window focus (for day rollover while the app stayed open overnight)
|
||||
```
|
||||
|
||||
**Why not hook off `kon:task-completed` alone:** that event only fires on completion, not on uncomplete or delete, and relying on it would leave the badge stale after either of those paths. Refreshing at the mutation-helper level captures every path that can change today's count.
|
||||
|
||||
**`src/lib/pages/TasksPage.svelte` header:**
|
||||
Stacks to:
|
||||
|
||||
```
|
||||
Tasks · 3 today ▁▂▅▃▁▆▄
|
||||
Add tasks manually…
|
||||
```
|
||||
|
||||
- Badge is an inline `<span>` after the "Tasks" title, matching title sizing but `text-text-tertiary` so it reads as subordinate. Hidden when `todayCount === 0`.
|
||||
- Sparkline is a ~80×16 px inline SVG of 7 `<rect>` bars. Zero-days render as a 1 px baseline stub. Same `text-text-tertiary` ink as the badge (SVG `fill="currentColor"` so the parent text colour drives it). Hidden when **either** the user has toggled it off **or** the full 7-day window is all zeros.
|
||||
- A single wrapper element around just the badge + sparkline carries `aria-live="polite"` (scope deliberately small so screen readers announce only the count change, not the rest of the header).
|
||||
|
||||
**Accessibility labels:**
|
||||
- Badge: `aria-label="3 tasks completed today"` (singular/plural aware).
|
||||
- Sparkline: `aria-label="Tasks completed over the last 7 days: 0, 1, 3, 2, 0, 4, 3"` with values from the current data.
|
||||
|
||||
### Settings
|
||||
|
||||
New preference: `showMomentumSparkline: boolean`, default `true`, persisted through the existing `saveSettings` path.
|
||||
|
||||
Settings page — in the same visual cluster as the existing energy / match-my-energy toggles — one toggle:
|
||||
|
||||
- Label: **"Show momentum sparkline"**
|
||||
- Helper: **"A tiny chart of the last 7 days' completion counts, shown on the Tasks header. Never counts against you."**
|
||||
|
||||
Only the sparkline respects the toggle. The badge is always on.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Day boundary: local time.
|
||||
- Subtasks count on explicit completion; auto-cascade parents do not.
|
||||
- Uncomplete removes from count.
|
||||
- Sparkline is 7 bars always (even if all zero; the 7-day-all-zero case hides the whole sparkline rather than showing a flat row — different from a single intra-week zero which renders as a 1 px stub).
|
||||
- Brand-new install: no badge (count=0), no sparkline (all 7 days=0).
|
||||
- Zero loss language anywhere. No "you were away", no "streak broken", no "4 days ago".
|
||||
|
||||
## Acceptance (from roadmap)
|
||||
|
||||
- Complete 3 tasks today → header shows "3 today".
|
||||
- Open the app after 4 days off → no "you were away" framing; header reads today's count only; sparkline renders flat zero-stubs for the 4 away days, real bars for the other 3.
|
||||
- Cascade-complete a parent via its last subtask → badge increments by 1 (the subtask), not 2.
|
||||
- Uncomplete a task that was completed today → badge decrements by 1.
|
||||
- Toggle "Show momentum sparkline" off → sparkline disappears; badge stays.
|
||||
|
||||
## Testing
|
||||
|
||||
### Rust (`cargo test` in `crates/storage`)
|
||||
- `list_recent_completions` returns exactly `days` entries in date order.
|
||||
- Zero-days are present as `{day, count: 0}`, not absent.
|
||||
- `auto_completed = 1` rows are excluded.
|
||||
- Manual subtask completion is counted.
|
||||
- `uncomplete_task` removes the row from the count.
|
||||
- Day boundary: insert `done_at` values at `23:59:59 UTC` on day N-1 and `00:00:01 UTC` on day N; query result correctly places each in the local day.
|
||||
- Migration v13: applies cleanly against a DB populated at v12; default of `auto_completed = 0` on pre-existing rows verified.
|
||||
|
||||
### Frontend (`npm run check` + vitest)
|
||||
- `completionStats` store refreshes on `kon:task-completed` event.
|
||||
- `todayCount` derives from the last entry.
|
||||
- Sparkline renders exactly 7 bars for a populated fixture.
|
||||
- Sparkline hidden when `showMomentumSparkline === false`.
|
||||
- Badge hidden when `todayCount === 0`.
|
||||
- Brand-new install (empty fixture): both elements hidden.
|
||||
|
||||
### Manual dogfood (folds into Phase 10a QC)
|
||||
- Complete 3 tasks. Header reads "3 today".
|
||||
- Uncomplete one. Header reads "2 today".
|
||||
- Micro-step a task, complete its last subtask. Header increments by 1 (subtask), not 2.
|
||||
- Leave the app open past local midnight. On focus, badge resets to "0" (hidden) and sparkline shifts left.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-day tooltip on the sparkline (Phase 9).
|
||||
- Motion curves / enter animations (Phase 9).
|
||||
- Dark-mode colour tweaks beyond what the tertiary ink already gives us (Phase 9).
|
||||
- Any analytics, telemetry, export, or CSV download (not a v0.1 concern).
|
||||
- Per-list or per-bucket breakdowns (everything aggregates across all tasks).
|
||||
- Future-dated completions (can't happen — `done_at` is always `now`).
|
||||
|
||||
## File map
|
||||
|
||||
- `crates/storage/src/migrations.rs` — migration v13 block.
|
||||
- `crates/storage/src/database.rs` — `complete_subtask_and_check_parent` flag set; `uncomplete_task` flag clear; new `list_recent_completions` fn + `DailyCompletionCount` struct.
|
||||
- `src-tauri/src/commands/...` (whichever file holds task commands) — `list_recent_completions_cmd` Tauri command.
|
||||
- `src/lib/stores/completionStats.svelte.ts` — new.
|
||||
- `src/lib/pages/TasksPage.svelte` — header markup for badge + sparkline; subscribe to store.
|
||||
- `src/lib/components/CompletionSparkline.svelte` — new, dedicated SVG component (keeps `TasksPage.svelte` focused).
|
||||
- `src/lib/types/app.ts` — `DailyCompletionCount` type; `showMomentumSparkline` added to settings type.
|
||||
- `src/lib/pages/SettingsPage.svelte` — toggle for `showMomentumSparkline`.
|
||||
|
||||
## Effort estimate
|
||||
|
||||
Half day, matching the roadmap estimate.
|
||||
306
docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md
Normal file
306
docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md
Normal file
@@ -0,0 +1,306 @@
|
||||
---
|
||||
name: Phase 9 — Polish debt (design)
|
||||
description: Design spec for Corbie Phase 9. Closes six polish-debt items from the feature-complete roadmap plus the Phase 8 carryover backlog. File-system save dialog, bulk History export, on-demand LLM content tags, progressive-disclosure Settings restructure, visual polish, accessibility sweep.
|
||||
type: spec
|
||||
tags: [spec, phase-9, corbie, polish, accessibility, settings, export, llm]
|
||||
created: 2026/04/24
|
||||
status: approved
|
||||
phase: 9
|
||||
roadmap: docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md
|
||||
author: Wren (CORBEL's resident agent) on behalf of Jake Sames
|
||||
---
|
||||
|
||||
# Phase 9 — Polish debt
|
||||
|
||||
## Summary
|
||||
|
||||
Close the last polish-debt items before Phase 10 (QC + rename + release). Six items from the roadmap plus the Phase 8 carryover backlog.
|
||||
|
||||
1. **File-system `.md` save dialog** — replaces clipboard-only export on HistoryPage + DictationPage + FilesPage.
|
||||
2. **Bulk select + bulk export in History** — multi-select rows, export as a directory of `.md` files.
|
||||
3. **LLM content tags** — on-demand `topic:*` + `intent:*` extraction using `kon-llm`, persisted on history rows.
|
||||
4. **Settings UX overhaul** — progressive disclosure. High-frequency settings always-visible; advanced behind `<details>` groups. Phase 8 sparkline toggle relocated out of Rituals.
|
||||
5. **Visual polish pass** — spacing, typography, motion curves, dark-mode parity. Absorbs Phase 8 motion backlog on badge + sparkline.
|
||||
6. **Accessibility pass** — keyboard navigation, focus order, screen reader labels (friendlier sparkline aria copy), WCAG AA contrast. Absorbs Phase 8 a11y backlog.
|
||||
|
||||
No new Rust crates. No new dependencies (`tauri-plugin-dialog` + `@tauri-apps/plugin-dialog` both already installed; capability already allowed).
|
||||
|
||||
This is the last phase before Phase 10. On completion: cargo + clippy + fmt + svelte-check + npm build all clean; manual dogfood walkthrough of items 1-6 passes; roadmap + HANDOVER updated.
|
||||
|
||||
## Design decisions locked 2026/04/24
|
||||
|
||||
1. **Save dialog** — no silent clipboard fallback if user cancels. Default filename derived from transcript title via the existing slug path (`<title or "transcript">-<YYYY-MM-DD>.md`).
|
||||
2. **Bulk export format** — directory of per-transcript files (chosen via `open({ directory: true })`). Concatenated single-file is not offered; users can copy-paste or open selected items to achieve it.
|
||||
3. **LLM tags** — on-demand per row plus a batch "Tag all untagged" affordance. Stored on `item.llmTags: string[]` alongside existing `manualTags`. Grammar-constrained JSON extraction: `{ topic: string, intent: enum }` with intent from a closed set. Re-generation replaces prior `llmTags` for that row (no history).
|
||||
4. **Settings restructure** — progressive disclosure. Top group "Start here" always expanded (model, microphone, hotkey, theme). Six collapsed `<details>` groups below: Transcription, Tasks, Rituals, Notifications, Accessibility, Advanced. Phase 8 sparkline toggle moves from Rituals to Tasks.
|
||||
5. **Visual polish + a11y — include in Phase 9**, not deferred to post-v0.1. Matches roadmap as written. Polish + a11y constrained to a checklist (see §Visual polish + §Accessibility below), not an open-ended pass.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Item 1 — File-system `.md` save dialog
|
||||
|
||||
**Rust side** (`src-tauri/src/commands/fs.rs`, new file — or added to an existing commands file if the one-command-per-file convention is loose):
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn write_text_file_cmd(path: String, contents: String) -> Result<(), String> {
|
||||
// Safety:
|
||||
// - Path comes from a user-selected save dialog. We do not validate
|
||||
// traversal or extension — the OS file picker already constrains
|
||||
// the user's choice to what they can write to.
|
||||
// - Parent directory is expected to exist (save dialog guarantees it).
|
||||
// - Writes UTF-8 via tokio::fs. No atomic-rename dance — a transcript
|
||||
// save is not mid-flight safety-critical; clobber on overwrite is fine.
|
||||
tokio::fs::write(&path, contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write {path}: {e}"))
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend call sites** — replace the `navigator.clipboard.writeText(md)` tail with:
|
||||
|
||||
```typescript
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
const defaultName = suggestedFilename(item); // "<slug>-<YYYY-MM-DD>.md"
|
||||
const path = await save({
|
||||
title: "Save transcript as Markdown",
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
});
|
||||
if (!path) return; // user cancelled — no toast, no fallback
|
||||
await invoke("write_text_file_cmd", { path, contents: md });
|
||||
toasts.success(`Saved to ${basename(path)}`);
|
||||
```
|
||||
|
||||
A new utility `src/lib/utils/saveMarkdown.ts` centralises `suggestedFilename` + `saveTranscriptAsMarkdown(item)` so three pages (HistoryPage / DictationPage / FilesPage) each call one function.
|
||||
|
||||
**Call site scope.** HistoryPage `exportMarkdown` (line 343) is the primary target. DictationPage + FilesPage currently do clipboard-only copies of raw transcript text (not markdown); they are out of scope for a markdown export button in Phase 9 — their clipboard semantics are right for their UX. Only HistoryPage exposes an "Export .md" button.
|
||||
|
||||
### Item 2 — Bulk select + bulk export in History
|
||||
|
||||
**Selection model.** New frontend-only state on HistoryPage: `let selected: Set<string> = $state(new Set())`. Not persisted across refresh. Selection toolbar appears when `selected.size > 0`.
|
||||
|
||||
**Selection UI.**
|
||||
- Checkbox on each row, left of existing content. Row click still opens the expanded view; only the checkbox toggles selection.
|
||||
- A header bar surfaces when selection is non-empty: "`N selected` · Select all / Clear · Export selected · Delete selected".
|
||||
- "Delete selected" uses existing `deleteFromHistory`, looped, with one confirm.
|
||||
|
||||
**Bulk export.**
|
||||
- `save` dialog in directory mode: `await open({ directory: true, title: "Choose export folder" })`.
|
||||
- For each selected item, call the same `buildMarkdown` path, then `write_text_file_cmd({ path: `${dir}/${suggestedFilename(item)}`, contents })`.
|
||||
- Collision handling: if a filename already exists in the chosen dir, append ` (N)` suffix until unique. Implement in the frontend (single OS-call query per filename is simpler than a Rust batch).
|
||||
- Toast on completion: "Exported N transcripts to …".
|
||||
|
||||
**Keyboard.** `Escape` clears selection. `Ctrl/Cmd+A` selects all visible rows when the HistoryPage has focus.
|
||||
|
||||
### Item 3 — LLM content tags
|
||||
|
||||
**Data model.** Add `llmTags: string[]` to history entries alongside `manualTags`. Persisted via a real SQLite column `transcripts.llm_tags TEXT NOT NULL DEFAULT ''` added in migration v14, exposed through an extended `update_transcript` Tauri command. (Earlier draft of this spec assumed the existing `saveHistory()` path was a real persist; closer review showed it's a no-op stub. The migration + command extension is in-scope as Task 8.5 of the plan, and additionally fixes a latent bug whereby existing `manualTags` edits weren't persisting either.)
|
||||
|
||||
**Tag schema.**
|
||||
- `topic:<1-3 noun phrase>` — free-form, lowercase, hyphen-joined. Examples: `topic:interview-prep`, `topic:grant-application`, `topic:personal-finance`. Limit one per transcript (the dominant subject).
|
||||
- `intent:<enum>` — closed set: `planning | reflection | venting | capture | decision | question`. Limit one.
|
||||
|
||||
**Generation.** New function in `crates/llm/src/prompts.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentTags {
|
||||
pub topic: String, // hyphen-joined, lowercase, 1-3 tokens
|
||||
pub intent: String, // one of INTENT_CLOSED_SET
|
||||
}
|
||||
|
||||
pub const INTENT_CLOSED_SET: &[&str] = &[
|
||||
"planning", "reflection", "venting", "capture", "decision", "question",
|
||||
];
|
||||
|
||||
pub async fn extract_content_tags(
|
||||
engine: &LlamaEngine,
|
||||
transcript: &str,
|
||||
) -> Result<ContentTags, EngineError>;
|
||||
```
|
||||
|
||||
Grammar-constrained GBNF in `crates/llm/src/grammars.rs` that restricts output to the schema:
|
||||
|
||||
```
|
||||
root ::= "{" ws "\"topic\":" ws topic "," ws "\"intent\":" ws intent ws "}"
|
||||
topic ::= "\"" [a-z0-9-]{3,60} "\""
|
||||
intent ::= "\"planning\"" | "\"reflection\"" | "\"venting\"" | "\"capture\"" | "\"decision\"" | "\"question\""
|
||||
ws ::= [ \t\n]*
|
||||
```
|
||||
|
||||
Prompt sketch (British English, deterministic, temperature 0.0):
|
||||
|
||||
```
|
||||
You tag a transcript with ONE topic and ONE intent.
|
||||
|
||||
TOPIC is a 1-3 token lowercase hyphen-joined noun phrase naming the dominant
|
||||
subject. Examples: "interview-prep", "grant-application", "daily-standup".
|
||||
|
||||
INTENT is exactly one of: planning, reflection, venting, capture, decision,
|
||||
question.
|
||||
|
||||
Return JSON only, with this exact shape:
|
||||
{"topic":"...","intent":"..."}
|
||||
|
||||
Transcript:
|
||||
<<<
|
||||
{{transcript}}
|
||||
>>>
|
||||
```
|
||||
|
||||
Guardrails:
|
||||
- Truncate transcript to the last 2000 chars if longer (LLM context budget).
|
||||
- On any error (JSON-parse, grammar rejection, timeout): return `Err`. Caller surfaces "Tagging failed — try again" toast, writes nothing.
|
||||
- Re-tagging a previously-tagged row replaces `llmTags`; no history.
|
||||
|
||||
**Tauri command.** `extract_content_tags_cmd(transcript: String) -> Result<ContentTags, String>` in `src-tauri/src/commands/llm.rs` (co-located with existing LLM commands, or a new file if absent).
|
||||
|
||||
**Frontend UX.**
|
||||
- "Tag" button per history row, in the row's action cluster (next to "Export .md"). Icon: `Tag` from `lucide-svelte`.
|
||||
- Click → spinner → on resolve: `llmTags = [`topic:${topic}`, `intent:${intent}`]`, `saveHistory()`.
|
||||
- Chips render in the tag area alongside manual tags but with a distinct visual treatment: `border-dashed`, italicised, subtly lighter ink. Click-through to promote to manual (`manualTags`), with animation-free chip move.
|
||||
- Batch affordance: on the History toolbar, "Tag all untagged" button (only shows if ≥ 1 untagged row). Iterates and calls the same command. Progress toast: "Tagged N / M…" updating every item.
|
||||
|
||||
### Item 4 — Settings restructure
|
||||
|
||||
**Pattern.** Single page (`SettingsPage.svelte`), flat scroll, no sidebar or tabs. Top block always expanded. Below it, six `<details>` groups each with a clear `<summary>` label.
|
||||
|
||||
**Architecture.**
|
||||
- New file `src/lib/components/SettingsGroup.svelte` — reusable `<details>` wrapper with styled chevron, summary padding, animated open/close (`@starting-style` + `interpolate-size: allow-keywords` for modern browsers; fall back to no animation).
|
||||
- Search box at the page top (new). Filters the visible controls by label text; empty filter shows everything. Non-matching groups collapse; matching groups open.
|
||||
- Settings content itself is not rewritten — only re-ordered + re-grouped. Each control keeps its current markup and behaviour.
|
||||
|
||||
**Group membership.**
|
||||
|
||||
| Group | Expanded by default? | Contents |
|
||||
|---|---|---|
|
||||
| **Start here** | Yes | Model download + status, microphone picker, global hotkey, theme (light/dark/system) |
|
||||
| **Transcription** | No | Whisper / Parakeet backend, language, punctuation, vocabulary profile, file-upload defaults |
|
||||
| **Tasks** | No | Match-my-energy default, WIP limit, **Show momentum sparkline** *(Phase 8, relocated)*, MicroSteps generation preset |
|
||||
| **Rituals** | No | Morning triage on/off + time, Evening shutdown on/off |
|
||||
| **Notifications** | No | Notifications enabled/muted, per-trigger toggles (inactivity, pending triage, micro-step idle), TTS nudge voice + rate |
|
||||
| **Accessibility** | No | Font size, line height, bionic reading, reduced motion, high contrast |
|
||||
| **Advanced** | No | Database path / open-data-dir button, export app data, reset settings, model tier override, debug logging |
|
||||
|
||||
**Phase 8 toggle relocation.** The `showMomentumSparkline` toggle moves from its current Rituals placement into the new **Tasks** group. Discovered in Phase 8 code review (carryover backlog).
|
||||
|
||||
**Open/close state.** Not persisted — groups reset to default on each app launch. Persisting open-state adds storage noise for marginal value; users who live in Settings can bookmark their group with a search query instead.
|
||||
|
||||
### Item 5 — Visual polish (bounded checklist)
|
||||
|
||||
Scoped list. Not an open-ended pass.
|
||||
|
||||
- **Motion on Phase 8 badge + sparkline.** Badge enters via a 180 ms opacity + 2 px translate-y on `todayCount` increment. Sparkline bars ease in with a 30 ms stagger on mount only (not per refresh). `prefers-reduced-motion` disables both.
|
||||
- **Typography scale audit.** One read-through of every page's `<h1>/<h2>/<h3>`; normalise to the three-step scale already used on `TasksPage`. No new fonts.
|
||||
- **Dark-mode parity check.** Toggle every page in dark mode once. Fix contrast regressions against WCAG AA minimum (see §Accessibility) and fix any baked-in `text-black` / `text-white` strings to `text-text` / `text-text-inverse`.
|
||||
- **Spacing pass on SettingsPage only.** Consistent `py-3` rows inside groups, `gap-4` between label + control, `pt-6` on group open. Other pages already converged in Phase 2.
|
||||
- **Lucide icon audit.** Any custom SVG chip or inline path that could swap to a Lucide icon — swap for visual consistency. List before changing (expect ≤ 5 swaps).
|
||||
|
||||
Deliberately **out of scope**: redesigning layouts, introducing new colours, changing the typographic scale, animating page transitions.
|
||||
|
||||
### Item 6 — Accessibility (bounded checklist)
|
||||
|
||||
- **Sparkline aria-label rewrite.** Replace numeric list (`"0, 1, 3, 2, 0, 4, 3"`) with friendlier summary: `"3 completed today. 14 total over the last 7 days."` Expose to any SR on focus of the sparkline SVG (add `tabindex="0"`, keyboard-focusable).
|
||||
- **Sparkline per-day tooltip.** On hover / keyboard-focus of each bar, show `<title>` with absolute date + count. Purely additive; doesn't change the existing aria-label structure.
|
||||
- **Keyboard traversal of every Phase 1-8 page.** Tab order starts top-left, ends bottom-right. No hidden focus sinks. Checklist per page.
|
||||
- **Focus-visible rings.** Every interactive element has a visible focus ring in both themes. The existing Tailwind `focus-visible:` ring utility is present but inconsistently applied — sweep.
|
||||
- **Screen reader labels on icon-only buttons.** Every `<button>` whose content is a Lucide icon gets an `aria-label`. Run a `grep -n 'aria-label' src/lib/pages src/lib/components` audit; flag missing ones.
|
||||
- **Contrast audit against WCAG AA.** Use the Chromium DevTools contrast panel on every page-level text colour in both themes. Target ratios: 4.5:1 body, 3:1 large headings / UI. Fix variables in the Tailwind theme, not per-site overrides.
|
||||
- **`prefers-reduced-motion` respected on every animation** added in Phase 5-9. Timers, sparkline, badge, settings accordion all guard the motion block.
|
||||
|
||||
## Testing
|
||||
|
||||
### Rust (`cargo test` in the touched crates)
|
||||
- `write_text_file_cmd` — round-trip a small UTF-8 string; assert file contents match and that nonexistent parent dir errors with a readable message.
|
||||
- `extract_content_tags` — integration test in `crates/llm/tests/` against a fixture transcript. Uses the real engine with a tiny tier-0 model. Assert topic matches a regex, intent is in the closed set, JSON parse succeeds.
|
||||
- Grammar unit test — feed a few malformed completions through the grammar parser and assert rejection.
|
||||
|
||||
### Frontend (`npm run check`)
|
||||
- `suggestedFilename` tests (new `src/lib/utils/saveMarkdown.test.ts` if a vitest config appears; otherwise covered in smoke dogfood).
|
||||
- `svelte-check` must be zero-zero across 3955+ files.
|
||||
|
||||
### Manual dogfood (folds into Phase 10a QC)
|
||||
- Export one transcript via save dialog. File written to chosen path. Cancel dialog → no toast, no write.
|
||||
- Select 3 history rows, export to a folder. Three files land with `suggestedFilename` names. Collision: repeat the export to the same folder; confirm ` (2)` / ` (3)` suffixes appear.
|
||||
- Tag one history row. Chip appears with dashed border. Click to promote; chip moves to manual and the dashed border disappears.
|
||||
- "Tag all untagged" processes remaining rows; toast updates progress; no duplicates.
|
||||
- Settings page: top group expanded; other groups collapsed. Search "sparkline" — filters to the Tasks group, opens it. Clear search — returns to default state.
|
||||
- Keyboard-only traversal of Dictation → Tasks → History → Settings pages. Every control reachable. Focus rings visible in both themes.
|
||||
- `prefers-reduced-motion` on — badge / sparkline / details animations all stop.
|
||||
|
||||
### Automated a11y gate (new)
|
||||
- Add a one-shot axe-core run to the dev cycle via `@axe-core/cli` invoked on `npm run build`'s preview URL. Document in HANDOVER but don't block merge on it (axe flags non-determinism that needs human triage); the CI gate stays `npm run check` + `cargo test`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `write_text_file_cmd` is only called with a path that came from a save / open dialog. Never hard-coded, never concatenated from user text input elsewhere. (Path-traversal risk is mitigated at the UI layer.)
|
||||
- LLM tags are additive to `manualTags`, never replace them. Promoted LLM tags leave `llmTags` untouched (no moving-between-arrays mutation).
|
||||
- Settings groups reset to their default expansion on each launch. The search box is the only persistence surface, and even it is not persisted.
|
||||
- No new user-facing string anywhere violates §Conventions: British English, no em / en dashes.
|
||||
- Motion in all Phase 9 additions respects `prefers-reduced-motion`.
|
||||
|
||||
## Acceptance (from roadmap)
|
||||
|
||||
All of:
|
||||
|
||||
1. Export one transcript from HistoryPage → `save()` dialog opens → file is written to the chosen path.
|
||||
2. Select 3 rows → Export selected → directory of 3 `.md` files lands in the chosen folder.
|
||||
3. Click "Tag" on one row → `topic:*` + `intent:*` chips appear within a few seconds.
|
||||
4. Settings page — top "Start here" group expanded; every other group collapsed; search filters + opens matching groups.
|
||||
5. Phase 8 sparkline toggle appears in Tasks group, not Rituals.
|
||||
6. Every interactive element in Dictation / Tasks / History / Settings is reachable by keyboard with a visible focus ring in both themes.
|
||||
7. Sparkline SR label reads: "3 completed today. 14 total over the last 7 days." (or the current day's equivalent numbers).
|
||||
8. `prefers-reduced-motion` disables badge entrance + sparkline stagger + settings accordion animation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Atomic file writes (rename-after-write) for the save dialog — a mid-write crash on a transcript export is not data-loss territory; the source stays intact in the History store.
|
||||
- Server-side or cloud tag storage. Tags stay local.
|
||||
- Re-training LLM on user corrections to tags. Tags are one-shot outputs.
|
||||
- Search-across-transcripts UI. Settings search is the only search surface added in Phase 9.
|
||||
- Moving SettingsPage to multiple files / router sub-routes. Stays as one scroll-view with groups.
|
||||
- Per-group open-state persistence (covered under "Open/close state" above).
|
||||
|
||||
## File map
|
||||
|
||||
### Created
|
||||
- `src-tauri/src/commands/fs.rs` *(or addition to existing commands file)* — `write_text_file_cmd`.
|
||||
- `src/lib/utils/saveMarkdown.ts` — `suggestedFilename`, `saveTranscriptAsMarkdown`, `exportTranscriptsToDir`.
|
||||
- `src/lib/components/SettingsGroup.svelte` — `<details>` wrapper with chevron + animation.
|
||||
- `crates/llm/tests/content_tags_smoke.rs` — integration test for `extract_content_tags`.
|
||||
- `docs/superpowers/plans/2026-04-24-phase9-polish-debt.md` — implementation plan (companion to this spec).
|
||||
|
||||
### Modified
|
||||
- `src-tauri/src/lib.rs` — register `write_text_file_cmd` + `extract_content_tags_cmd`.
|
||||
- `crates/llm/src/prompts.rs` — add `ContentTags`, `INTENT_CLOSED_SET`, `extract_content_tags`.
|
||||
- `crates/llm/src/grammars.rs` — add content-tag GBNF grammar.
|
||||
- `crates/llm/src/lib.rs` — re-export the new symbols.
|
||||
- `src-tauri/src/commands/*.rs` — add `extract_content_tags_cmd` (file choice depends on the existing LLM command placement).
|
||||
- `src/lib/pages/HistoryPage.svelte` — save dialog, bulk select, bulk export, LLM tag button, llmTags chip rendering.
|
||||
- `src/lib/pages/SettingsPage.svelte` — full regrouping into the 7 groups (Start here + 6 collapsed). Sparkline toggle relocation.
|
||||
- `src/lib/types/app.ts` — `llmTags` field on history entry type; optional `ContentTags` type.
|
||||
- `src/lib/stores/page.svelte.ts` — hydrate / persist `llmTags` alongside `manualTags`.
|
||||
- `src/lib/utils/frontmatter.ts` — include `llmTags` in `buildFrontmatter`'s `tags` union.
|
||||
- `src/lib/components/CompletionSparkline.svelte` — friendlier aria-label, per-bar `<title>` tooltips, `tabindex="0"` for SR focus.
|
||||
- `src/lib/pages/TasksPage.svelte` — badge motion on increment (behind `prefers-reduced-motion`).
|
||||
- Tailwind theme if contrast audit forces colour variable changes.
|
||||
|
||||
### Deleted
|
||||
None.
|
||||
|
||||
## Effort estimate
|
||||
|
||||
1 to 2 days of focused work. Items 1 + 2 ≈ half day. Item 3 ≈ half day (includes the grammar + smoke test). Item 4 ≈ half day (the regrouping + search). Items 5 + 6 ≈ half day combined (checklists, not discoveries).
|
||||
|
||||
Natural split for the plan: 9a (items 1 + 2), 9b (item 3), 9c (item 4), 9d (items 5 + 6).
|
||||
|
||||
## Anchors
|
||||
|
||||
- Roadmap: [docs/roadmap/2026-04-23-corbie-feature-complete-roadmap.md](../../roadmap/2026-04-23-corbie-feature-complete-roadmap.md)
|
||||
- Phase 8 plan + spec + handover — for task-format reference and carryover backlog.
|
||||
- Phase 8 gotchas to respect in this phase:
|
||||
- `$derived` does not export at `.svelte.ts` module scope — use `export function` instead.
|
||||
- `#[derive(sqlx::FromRow)]` is not available in `kon-storage` (no change in Phase 9, but noted if schema work drifts there).
|
||||
- Progressive-disclosure pattern reference: NN/g on progressive disclosure; Material Design settings patterns; UX Collective on settings redesign.
|
||||
110
package-lock.json
generated
110
package-lock.json
generated
@@ -11,15 +11,17 @@
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "0.0.5",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"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 +29,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 +960,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 +989,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 +1264,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",
|
||||
@@ -1538,6 +1604,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-autostart": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
|
||||
"integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
@@ -1556,6 +1631,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
@@ -2375,9 +2459,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 +3182,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": {
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "0.0.5",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"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 +34,6 @@
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,17 @@ kon-cloud-providers = { path = "../crates/cloud-providers" }
|
||||
kon-hotkey = { path = "../crates/hotkey" }
|
||||
kon-llm = { path = "../crates/llm" }
|
||||
|
||||
# Tauri
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
# Tauri. The `tray-icon` feature, the global-shortcut/window-state/
|
||||
# autostart plugins are desktop-only — gated below under
|
||||
# cfg(not(target_os = "android")). The dialog, opener, and notification
|
||||
# plugins all support Android natively so live in the unconditional list.
|
||||
tauri = { version = "2" }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge
|
||||
# bus. Permission-request flow happens on the JS side before the first
|
||||
# deliver_nudge call; Windows only delivers for installed apps.
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -67,6 +71,30 @@ libloading = "0.8"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Phase 9 fs::write_text_file_cmd tests use a temp directory so we don't
|
||||
# pollute the workspace.
|
||||
tempfile = "3"
|
||||
|
||||
# Desktop-only Tauri features and plugins. Each item below either fails
|
||||
# to compile against the Android NDK or is a structural no-op on a
|
||||
# single-window mobile app:
|
||||
# - tray-icon: no system tray on mobile.
|
||||
# - global-shortcut: mobile has no global hotkey API; the foreground
|
||||
# notification with a record button is the Android replacement.
|
||||
# - window-state: single-window, fullscreen, no per-window persistence.
|
||||
# - autostart: mobile apps don't auto-start in the desktop sense; if
|
||||
# we ever want a "boot completed" handler that's a separate Android
|
||||
# intent receiver, not this plugin.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
# Phase 5 rituals: register Corbie as a login-time autostart entry.
|
||||
# Handles platform differences (.desktop file on Linux, LaunchAgents plist
|
||||
# on macOS, registry Run key on Windows) behind a single API.
|
||||
tauri-plugin-autostart = "2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
# Needed for setting the preview overlay's WindowTypeHint to Utility via
|
||||
@@ -78,3 +106,8 @@ gdk = "0.18"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.4"
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64.
|
||||
# Windows-only because the other platforms' TTS paths pass text via argv.
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -9,12 +9,12 @@ fn main() {
|
||||
println!("cargo:rustc-link-arg=-Wl,--allow-multiple-definition");
|
||||
}
|
||||
|
||||
assert_localhost_llm_csp();
|
||||
assert_loopback_llm_csp();
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
/// Regression guard for brief item #2 (pre-emptive localhost LLM scope).
|
||||
/// Regression guard for brief item #2 (pre-emptive loopback LLM scope).
|
||||
///
|
||||
/// Kon's bundled llama.cpp server and any BYO Ollama install speak HTTP
|
||||
/// on `127.0.0.1:*`. If the `connect-src` CSP ever drops those entries,
|
||||
@@ -25,9 +25,9 @@ fn main() {
|
||||
/// Parses `tauri.conf.json` properly and inspects the `connect-src`
|
||||
/// directive of the CSP, rather than substring-searching the whole
|
||||
/// file — the latter would both false-pass on unrelated values
|
||||
/// containing a localhost URL and false-fail on JSON-escaped forward
|
||||
/// containing a loopback URL and false-fail on JSON-escaped forward
|
||||
/// slashes.
|
||||
fn assert_localhost_llm_csp() {
|
||||
fn assert_loopback_llm_csp() {
|
||||
let conf_path = std::path::Path::new("tauri.conf.json");
|
||||
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||
|
||||
@@ -64,10 +64,18 @@ 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:?}"
|
||||
);
|
||||
}
|
||||
for forbidden in ["http://localhost:*", "ws://localhost:*"] {
|
||||
assert!(
|
||||
!tokens.contains(&forbidden),
|
||||
"build.rs: tauri.conf.json CSP connect-src must not permit {forbidden}; \
|
||||
normalize local LLM endpoints to 127.0.0.1 instead. Current connect-src: \
|
||||
{connect_src:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "tasks-float", "transcript-viewer", "transcription-preview"],
|
||||
"identifier": "main",
|
||||
"description": "Main window capability for user-initiated app control.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
@@ -17,6 +17,12 @@
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister"
|
||||
"global-shortcut:allow-unregister",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify"
|
||||
]
|
||||
}
|
||||
14
src-tauri/capabilities/secondary-windows.json
Normal file
14
src-tauri/capabilities/secondary-windows.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "secondary-windows",
|
||||
"description": "Narrow capability for passive secondary windows.",
|
||||
"windows": ["tasks-float", "transcript-viewer", "transcription-preview"],
|
||||
"permissions": [
|
||||
"core:event:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
|
||||
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}
|
||||
@@ -344,6 +344,48 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
|
||||
"type": "string",
|
||||
"const": "autostart:default",
|
||||
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-disable",
|
||||
"markdownDescription": "Enables the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-enable",
|
||||
"markdownDescription": "Enables the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-is-enabled",
|
||||
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-disable",
|
||||
"markdownDescription": "Denies the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-enable",
|
||||
"markdownDescription": "Denies the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-is-enabled",
|
||||
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
@@ -2313,22 +2355,22 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2349,16 +2391,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
@@ -2444,6 +2486,204 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
@@ -2492,60 +2732,6 @@
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -344,6 +344,48 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
|
||||
"type": "string",
|
||||
"const": "autostart:default",
|
||||
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-disable",
|
||||
"markdownDescription": "Enables the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-enable",
|
||||
"markdownDescription": "Enables the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:allow-is-enabled",
|
||||
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the disable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-disable",
|
||||
"markdownDescription": "Denies the disable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the enable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-enable",
|
||||
"markdownDescription": "Denies the enable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_enabled command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "autostart:deny-is-enabled",
|
||||
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
@@ -2313,22 +2355,22 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2349,16 +2391,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
@@ -2444,6 +2486,204 @@
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
@@ -2492,60 +2732,6 @@
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "updater:default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "updater:deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::{mpsc as tokio_mpsc, Mutex as AsyncMutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use kon_audio::{DeviceInfo, MicrophoneCapture};
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use kon_audio::{DeviceInfo, MicrophoneCapture, WavWriter};
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use kon_core::types::AudioSamples;
|
||||
|
||||
const MAX_NATIVE_CAPTURE_RETURN_SAMPLES: usize = WHISPER_SAMPLE_RATE as usize * 60 * 10;
|
||||
|
||||
/// Enumerate every input device available to cpal, with metadata for the
|
||||
/// Settings device-picker UI. Includes a flag for likely PulseAudio /
|
||||
/// PipeWire monitor sources so the UI can warn the user.
|
||||
#[tauri::command]
|
||||
pub async fn list_audio_devices() -> Result<Vec<DeviceInfo>, String> {
|
||||
pub async fn list_audio_devices(window: tauri::WebviewWindow) -> Result<Vec<DeviceInfo>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
tokio::task::spawn_blocking(MicrophoneCapture::list_devices)
|
||||
.await
|
||||
.map_err(|e| format!("join error: {e}"))?
|
||||
@@ -51,8 +56,12 @@ pub struct NativeCaptureState {
|
||||
/// holding the lock — a `std::sync::Mutex` would have to be released
|
||||
/// and reacquired around each await point.
|
||||
worker: AsyncMutex<Option<CaptureWorker>>,
|
||||
/// All captured samples (16kHz mono) for save_audio.
|
||||
/// Compatibility buffer returned by stop_native_capture. Capped; the
|
||||
/// authoritative capture is streamed to temp WAV on disk.
|
||||
all_samples: Arc<Mutex<Vec<f32>>>,
|
||||
wav_writer: Arc<Mutex<Option<WavWriter>>>,
|
||||
temp_audio_path: Arc<Mutex<Option<PathBuf>>>,
|
||||
capture_truncated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl NativeCaptureState {
|
||||
@@ -60,6 +69,36 @@ impl NativeCaptureState {
|
||||
Self {
|
||||
worker: AsyncMutex::new(None),
|
||||
all_samples: Arc::new(Mutex::new(Vec::new())),
|
||||
wav_writer: Arc::new(Mutex::new(None)),
|
||||
temp_audio_path: Arc::new(Mutex::new(None)),
|
||||
capture_truncated: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_recorded_chunk(
|
||||
all_samples: &Arc<Mutex<Vec<f32>>>,
|
||||
wav_writer: &Arc<Mutex<Option<WavWriter>>>,
|
||||
truncated: &Arc<AtomicBool>,
|
||||
chunk: &[f32],
|
||||
) {
|
||||
if let Ok(mut writer) = wav_writer.lock() {
|
||||
if let Some(writer) = writer.as_mut() {
|
||||
if let Err(e) = writer.append(chunk) {
|
||||
eprintln!("[native-capture] temp WAV append failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut all) = all_samples.lock() {
|
||||
let remaining = MAX_NATIVE_CAPTURE_RETURN_SAMPLES.saturating_sub(all.len());
|
||||
if remaining >= chunk.len() {
|
||||
all.extend_from_slice(chunk);
|
||||
} else {
|
||||
if remaining > 0 {
|
||||
all.extend_from_slice(&chunk[..remaining]);
|
||||
}
|
||||
truncated.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,10 +111,12 @@ impl NativeCaptureState {
|
||||
/// user's pick from Settings → Audio → Microphone takes effect.
|
||||
#[tauri::command]
|
||||
pub async fn start_native_capture(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, NativeCaptureState>,
|
||||
device_name: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
eprintln!(
|
||||
"[native-capture] start_native_capture called (device='{}')",
|
||||
device_name.as_deref().unwrap_or("<auto>")
|
||||
@@ -116,10 +157,19 @@ pub async fn start_native_capture(
|
||||
|
||||
let all_samples = state.all_samples.clone();
|
||||
all_samples.lock().unwrap().clear();
|
||||
state.capture_truncated.store(false, Ordering::Relaxed);
|
||||
|
||||
let temp_path = std::env::temp_dir().join(recording_filename());
|
||||
let writer = WavWriter::create(&temp_path, WHISPER_SAMPLE_RATE, 1)
|
||||
.map_err(|e| format!("Failed to create temp capture WAV: {e}"))?;
|
||||
*state.wav_writer.lock().unwrap() = Some(writer);
|
||||
*state.temp_audio_path.lock().unwrap() = Some(temp_path);
|
||||
|
||||
let (stop_tx, mut stop_rx) = tokio_mpsc::channel::<()>(1);
|
||||
|
||||
let all_samples_clone = all_samples.clone();
|
||||
let wav_writer_clone = state.wav_writer.clone();
|
||||
let truncated_clone = state.capture_truncated.clone();
|
||||
|
||||
// Spawn a task that reads cpal chunks, downsamples to 16kHz mono,
|
||||
// and emits events to the frontend. The JoinHandle is retained in
|
||||
@@ -189,10 +239,12 @@ pub async fn start_native_capture(
|
||||
while pcm_buffer.len() >= chunk_size {
|
||||
let chunk: Vec<f32> = pcm_buffer.drain(..chunk_size).collect();
|
||||
|
||||
// Store for save_audio
|
||||
if let Ok(mut all) = all_samples_clone.lock() {
|
||||
all.extend_from_slice(&chunk);
|
||||
}
|
||||
append_recorded_chunk(
|
||||
&all_samples_clone,
|
||||
&wav_writer_clone,
|
||||
&truncated_clone,
|
||||
&chunk,
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"native-pcm",
|
||||
@@ -213,9 +265,12 @@ pub async fn start_native_capture(
|
||||
|
||||
// Emit any remaining samples
|
||||
if !pcm_buffer.is_empty() {
|
||||
if let Ok(mut all) = all_samples_clone.lock() {
|
||||
all.extend_from_slice(&pcm_buffer);
|
||||
}
|
||||
append_recorded_chunk(
|
||||
&all_samples_clone,
|
||||
&wav_writer_clone,
|
||||
&truncated_clone,
|
||||
&pcm_buffer,
|
||||
);
|
||||
let _ = app.emit(
|
||||
"native-pcm",
|
||||
serde_json::json!({
|
||||
@@ -228,6 +283,13 @@ pub async fn start_native_capture(
|
||||
if let Ok(mut cap) = capture_clone.lock() {
|
||||
cap.take();
|
||||
}
|
||||
if let Ok(mut writer) = wav_writer_clone.lock() {
|
||||
if let Some(writer) = writer.take() {
|
||||
if let Err(e) = writer.finalize() {
|
||||
eprintln!("[native-capture] temp WAV finalize failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*state.worker.lock().await = Some(CaptureWorker { stop_tx, join });
|
||||
@@ -242,8 +304,10 @@ pub async fn start_native_capture(
|
||||
/// nothing from a worker that technically outlived the call (RB-06).
|
||||
#[tauri::command]
|
||||
pub async fn stop_native_capture(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, NativeCaptureState>,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
if let Some(worker) = state.worker.lock().await.take() {
|
||||
stop_worker(worker).await;
|
||||
}
|
||||
@@ -252,6 +316,11 @@ pub async fn stop_native_capture(
|
||||
let mut all = state.all_samples.lock().unwrap();
|
||||
std::mem::take(&mut *all)
|
||||
};
|
||||
if state.capture_truncated.load(Ordering::Relaxed) {
|
||||
eprintln!(
|
||||
"[native-capture] stop_native_capture returned a capped compatibility buffer; temp WAV contains the full capture"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(samples)
|
||||
}
|
||||
@@ -461,9 +530,11 @@ pub async fn persist_audio_samples(
|
||||
/// Save PCM f32 samples as a WAV file. Returns the file path.
|
||||
#[tauri::command]
|
||||
pub async fn save_audio(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
output_folder: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
persist_audio_samples(&app, samples, output_folder).await
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use kon_storage::{
|
||||
};
|
||||
|
||||
use crate::commands::power::active_assertions_snapshot;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
|
||||
const DEFAULT_RECENT_ERRORS: i64 = 50;
|
||||
@@ -136,9 +137,11 @@ impl From<ErrorLogRow> for ErrorLogDto {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_recent_errors_command(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<ErrorLogDto>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000);
|
||||
list_recent_errors(&state.db, n)
|
||||
.await
|
||||
@@ -157,7 +160,12 @@ pub struct CrashFile {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_crash_files() -> Result<Vec<CrashFile>, String> {
|
||||
pub async fn list_crash_files(window: tauri::WebviewWindow) -> Result<Vec<CrashFile>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
list_crash_files_inner().await
|
||||
}
|
||||
|
||||
async fn list_crash_files_inner() -> Result<Vec<CrashFile>, String> {
|
||||
let dir = crashes_dir();
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
@@ -215,12 +223,72 @@ impl Default for ReportOptions {
|
||||
Self {
|
||||
include_recent_errors: true,
|
||||
include_crashes: true,
|
||||
include_settings: true,
|
||||
include_log_tail: true,
|
||||
include_settings: false,
|
||||
include_log_tail: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_home(input: &str) -> String {
|
||||
match std::env::var("HOME") {
|
||||
Ok(home) if !home.is_empty() => input.replace(&home, "~"),
|
||||
_ => input.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_sensitive_key(key: &str) -> bool {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
lower.contains("path")
|
||||
|| lower.contains("folder")
|
||||
|| lower.contains("directory")
|
||||
|| lower.contains("device")
|
||||
|| lower.contains("microphone")
|
||||
}
|
||||
|
||||
fn redact_json_value(value: &mut serde_json::Value, key_hint: Option<&str>) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for (key, child) in map.iter_mut() {
|
||||
redact_json_value(child, Some(key));
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
redact_json_value(item, key_hint);
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(text) => {
|
||||
if key_hint.map(looks_sensitive_key).unwrap_or(false)
|
||||
|| text.starts_with('/')
|
||||
|| text.contains(":\\")
|
||||
|| text.contains("/home/")
|
||||
|| text.contains("/Users/")
|
||||
{
|
||||
*text = redact_home(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitise_preferences_json(raw: &str) -> String {
|
||||
match serde_json::from_str::<serde_json::Value>(raw) {
|
||||
Ok(mut value) => {
|
||||
redact_json_value(&mut value, None);
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
Err(_) => "_preferences could not be parsed as JSON_".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_line(input: &str) -> String {
|
||||
redact_home(input)
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
.replace('\n', " ")
|
||||
}
|
||||
|
||||
/// Build a single human-readable diagnostic-report string. The user inspects
|
||||
/// this in Settings → About before deciding whether to copy/save/email it.
|
||||
///
|
||||
@@ -228,8 +296,17 @@ impl Default for ReportOptions {
|
||||
/// Discord thread, or a GitHub issue without further conversion.
|
||||
#[tauri::command]
|
||||
pub async fn generate_diagnostic_report(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
options: Option<ReportOptions>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
generate_diagnostic_report_inner(&state, options).await
|
||||
}
|
||||
|
||||
async fn generate_diagnostic_report_inner(
|
||||
state: &tauri::State<'_, AppState>,
|
||||
options: Option<ReportOptions>,
|
||||
) -> Result<String, String> {
|
||||
let opts = options.unwrap_or_default();
|
||||
let mut out = String::new();
|
||||
@@ -241,13 +318,16 @@ pub async fn generate_diagnostic_report(
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
));
|
||||
out.push_str(&format!("- App data dir: `{}`\n", app_data_dir().display()));
|
||||
out.push_str(&format!(
|
||||
"- App data dir: `{}`\n",
|
||||
redact_home(&app_data_dir().display().to_string())
|
||||
));
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.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",
|
||||
@@ -258,7 +338,7 @@ pub async fn generate_diagnostic_report(
|
||||
match kon_storage::get_setting(&state.db, "kon_preferences").await {
|
||||
Ok(Some(json)) => {
|
||||
out.push_str("```json\n");
|
||||
out.push_str(&json);
|
||||
out.push_str(&sanitise_preferences_json(&json));
|
||||
out.push_str("\n```\n\n");
|
||||
}
|
||||
Ok(None) => out.push_str("_(no preferences saved)_\n\n"),
|
||||
@@ -278,11 +358,7 @@ pub async fn generate_diagnostic_report(
|
||||
r.context,
|
||||
r.error_code.as_deref().unwrap_or("?"),
|
||||
if r.metadata.is_some() { " (+meta)" } else { "" },
|
||||
r.message
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
.replace('\n', " "),
|
||||
redact_line(&r.message),
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
@@ -307,7 +383,7 @@ pub async fn generate_diagnostic_report(
|
||||
|
||||
if opts.include_crashes {
|
||||
out.push_str("## Crash dumps\n\n");
|
||||
let crashes = list_crash_files().await.unwrap_or_default();
|
||||
let crashes = list_crash_files_inner().await.unwrap_or_default();
|
||||
if crashes.is_empty() {
|
||||
out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n");
|
||||
} else {
|
||||
@@ -321,7 +397,7 @@ pub async fn generate_diagnostic_report(
|
||||
out.push_str(&format!(
|
||||
"_(plus {} older crash dumps in `{}`)_\n\n",
|
||||
crashes.len() - 5,
|
||||
crashes_dir().display()
|
||||
redact_home(&crashes_dir().display().to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -336,7 +412,7 @@ pub async fn generate_diagnostic_report(
|
||||
}
|
||||
Ok(tail) => {
|
||||
out.push_str("```\n");
|
||||
out.push_str(&tail);
|
||||
out.push_str(&redact_home(&tail));
|
||||
out.push_str("\n```\n\n");
|
||||
}
|
||||
Err(_) => out.push_str("_(no log file found)_\n\n"),
|
||||
@@ -435,14 +511,16 @@ pub fn get_os_info() -> OsInfo {
|
||||
/// somewhere they can attach to an email.
|
||||
#[tauri::command]
|
||||
pub async fn save_diagnostic_report(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
options: Option<ReportOptions>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let _ = app; // reserved for future dialog integration
|
||||
let report = generate_diagnostic_report(state, options).await?;
|
||||
let report = generate_diagnostic_report_inner(&state, options).await?;
|
||||
|
||||
let dir = app_data_dir().join("diagnostic-reports");
|
||||
let dir = kon_storage::app_data_dir().join("diagnostic-reports");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?;
|
||||
|
||||
let ts = SystemTime::now()
|
||||
|
||||
110
src-tauri/src/commands/feedback.rs
Normal file
110
src-tauri/src/commands/feedback.rs
Normal 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())
|
||||
}
|
||||
44
src-tauri/src/commands/fs.rs
Normal file
44
src-tauri/src/commands/fs.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// Phase 9. Thin filesystem write command for the save-dialog path.
|
||||
//
|
||||
// Path safety: the caller is expected to obtain `path` via the OS save
|
||||
// dialog (tauri-plugin-dialog). We do not validate traversal, extension,
|
||||
// or parent-dir existence beyond what the OS filesystem reports — the
|
||||
// dialog already constrains the user's choice to what they can write to.
|
||||
|
||||
/// Write UTF-8 text to a user-chosen path. Errors propagate the OS
|
||||
/// message verbatim wrapped with the path so the toast on the frontend
|
||||
/// is actionable ("Failed to write …: Permission denied" rather than a
|
||||
/// bare error code).
|
||||
#[tauri::command]
|
||||
pub async fn write_text_file_cmd(path: String, contents: String) -> Result<(), String> {
|
||||
tokio::fs::write(&path, contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write {path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_text_file_roundtrips_utf8() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("out.md").display().to_string();
|
||||
write_text_file_cmd(path.clone(), "hello\nwørld\n".into())
|
||||
.await
|
||||
.expect("write");
|
||||
|
||||
let round = tokio::fs::read_to_string(&path).await.expect("read");
|
||||
assert_eq!(round, "hello\nwørld\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_text_file_errors_on_bad_parent() {
|
||||
let result = write_text_file_cmd(
|
||||
"/definitely-not-a-real-path-kon-phase9/out.md".into(),
|
||||
"x".into(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "expected error for nonexistent parent");
|
||||
}
|
||||
}
|
||||
308
src-tauri/src/commands/intentions.rs
Normal file
308
src-tauri/src/commands/intentions.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
//! Phase 7 implementation intentions: small if-then automation rules.
|
||||
//!
|
||||
//! The frontend owns scheduling and execution because the v1 triggers are
|
||||
//! app-local events (timer/task/ritual) plus local clock checks. Rust owns
|
||||
//! durable rule storage, validation, and main-window-only command access.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use kon_storage::{
|
||||
delete_implementation_rule as db_delete_rule, get_task_by_id,
|
||||
insert_implementation_rule as db_insert_rule, list_implementation_rules as db_list_rules,
|
||||
mark_implementation_rule_fired as db_mark_rule_fired,
|
||||
set_implementation_rule_enabled as db_set_rule_enabled, ImplementationRuleRow,
|
||||
};
|
||||
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleTriggerKind {
|
||||
TimeOfDay,
|
||||
TaskCompleted,
|
||||
MorningTriageFinished,
|
||||
}
|
||||
|
||||
impl RuleTriggerKind {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RuleTriggerKind::TimeOfDay => "time_of_day",
|
||||
RuleTriggerKind::TaskCompleted => "task_completed",
|
||||
RuleTriggerKind::MorningTriageFinished => "morning_triage_finished",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SurfaceTarget {
|
||||
Inbox,
|
||||
Today,
|
||||
Tasks,
|
||||
Task,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RuleAction {
|
||||
Surface {
|
||||
target: SurfaceTarget,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "taskId")]
|
||||
task_id: Option<String>,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
},
|
||||
StartTimer {
|
||||
seconds: u32,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "taskId")]
|
||||
task_id: Option<String>,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
},
|
||||
SpeakLine {
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateImplementationRuleRequest {
|
||||
pub trigger_kind: RuleTriggerKind,
|
||||
pub trigger_value: String,
|
||||
pub actions: Vec<RuleAction>,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_fired_key: Option<String>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImplementationRuleDto {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
pub trigger_kind: RuleTriggerKind,
|
||||
pub trigger_value: String,
|
||||
pub actions: Vec<RuleAction>,
|
||||
pub last_fired_key: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl TryFrom<ImplementationRuleRow> for ImplementationRuleDto {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(row: ImplementationRuleRow) -> Result<Self, Self::Error> {
|
||||
let trigger_kind = match row.trigger_kind.as_str() {
|
||||
"time_of_day" => RuleTriggerKind::TimeOfDay,
|
||||
"task_completed" => RuleTriggerKind::TaskCompleted,
|
||||
"morning_triage_finished" => RuleTriggerKind::MorningTriageFinished,
|
||||
other => return Err(format!("unknown rule trigger kind: {other}")),
|
||||
};
|
||||
let actions = serde_json::from_str::<Vec<RuleAction>>(&row.actions_json)
|
||||
.map_err(|e| format!("invalid rule actions JSON for {}: {e}", row.id))?;
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
enabled: row.enabled,
|
||||
trigger_kind,
|
||||
trigger_value: row.trigger_value,
|
||||
actions,
|
||||
last_fired_key: row.last_fired_key,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_hhmm(value: &str) -> Result<(), String> {
|
||||
let (h, m) = value
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "time rules need HH:MM".to_string())?;
|
||||
if h.len() != 2 || m.len() != 2 {
|
||||
return Err("time rules need HH:MM".into());
|
||||
}
|
||||
let hour: u8 = h
|
||||
.parse()
|
||||
.map_err(|_| "time hour must be 00-23".to_string())?;
|
||||
let minute: u8 = m
|
||||
.parse()
|
||||
.map_err(|_| "time minute must be 00-59".to_string())?;
|
||||
if hour > 23 || minute > 59 {
|
||||
return Err("time must be between 00:00 and 23:59".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_actions(
|
||||
state: &tauri::State<'_, AppState>,
|
||||
actions: &[RuleAction],
|
||||
) -> Result<(), String> {
|
||||
if actions.is_empty() {
|
||||
return Err("add at least one rule action".into());
|
||||
}
|
||||
for action in actions {
|
||||
match action {
|
||||
RuleAction::Surface {
|
||||
target,
|
||||
task_id,
|
||||
label: _,
|
||||
} => {
|
||||
if matches!(target, SurfaceTarget::Task) {
|
||||
let task_id = task_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| "surface-task actions need a task".to_string())?;
|
||||
let exists = get_task_by_id(&state.db, task_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some();
|
||||
if !exists {
|
||||
return Err(format!("task {task_id} does not exist"));
|
||||
}
|
||||
}
|
||||
}
|
||||
RuleAction::StartTimer { seconds, .. } => {
|
||||
if *seconds != 300 {
|
||||
return Err("v1 rule timers are fixed at 5 minutes".into());
|
||||
}
|
||||
}
|
||||
RuleAction::SpeakLine { text } => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("speak actions need a line to read".into());
|
||||
}
|
||||
if trimmed.chars().count() > 240 {
|
||||
return Err("speak lines must be 240 characters or fewer".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_request(
|
||||
state: &tauri::State<'_, AppState>,
|
||||
request: &CreateImplementationRuleRequest,
|
||||
) -> Result<(), String> {
|
||||
match request.trigger_kind {
|
||||
RuleTriggerKind::TimeOfDay => validate_hhmm(request.trigger_value.trim())?,
|
||||
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => {
|
||||
if !request.trigger_value.trim().is_empty() {
|
||||
return Err("this trigger does not take an extra value".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_actions(state, &request.actions).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_implementation_rules(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Vec<ImplementationRuleDto>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
db_list_rules(&state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(ImplementationRuleDto::try_from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_implementation_rule(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: CreateImplementationRuleRequest,
|
||||
) -> Result<ImplementationRuleDto, String> {
|
||||
ensure_main_window(&window)?;
|
||||
validate_request(&state, &request).await?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let actions_json = serde_json::to_string(&request.actions)
|
||||
.map_err(|e| format!("serialise rule actions failed: {e}"))?;
|
||||
let trigger_value = match request.trigger_kind {
|
||||
RuleTriggerKind::TimeOfDay => request.trigger_value.trim().to_string(),
|
||||
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => String::new(),
|
||||
};
|
||||
|
||||
db_insert_rule(
|
||||
&state.db,
|
||||
&id,
|
||||
request.enabled,
|
||||
request.trigger_kind.as_str(),
|
||||
&trigger_value,
|
||||
&actions_json,
|
||||
request.last_fired_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(ImplementationRuleDto::try_from)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_implementation_rule_enabled(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<ImplementationRuleDto, String> {
|
||||
ensure_main_window(&window)?;
|
||||
db_set_rule_enabled(&state.db, &id, enabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(ImplementationRuleDto::try_from)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mark_implementation_rule_fired(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
fired_key: String,
|
||||
) -> Result<ImplementationRuleDto, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let key = fired_key.trim();
|
||||
if key.is_empty() {
|
||||
return Err("fired_key is required".into());
|
||||
}
|
||||
db_mark_rule_fired(&state.db, &id, key)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(ImplementationRuleDto::try_from)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_implementation_rule(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
db_delete_rule(&state.db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_hhmm;
|
||||
|
||||
#[test]
|
||||
fn hhmm_validation_accepts_and_rejects_expected_shapes() {
|
||||
assert!(validate_hhmm("09:00").is_ok());
|
||||
assert!(validate_hhmm("23:59").is_ok());
|
||||
assert!(validate_hhmm("9:00").is_err());
|
||||
assert!(validate_hhmm("24:00").is_err());
|
||||
assert!(validate_hhmm("08:60").is_err());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use crate::commands::audio::resolve_recording_path;
|
||||
use crate::commands::build_initial_prompt;
|
||||
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
use kon_audio::{
|
||||
@@ -299,6 +300,7 @@ impl LiveSessionRuntime {
|
||||
&self.dictionary_terms,
|
||||
&self.result_channel,
|
||||
&self.status_channel,
|
||||
&self.stop_flag,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -481,6 +483,7 @@ struct SpeechGateDecision {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_live_transcription_session(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
live_state: tauri::State<'_, LiveTranscriptionState>,
|
||||
@@ -488,6 +491,7 @@ pub async fn start_live_transcription_session(
|
||||
result_channel: Channel<LiveResultMessage>,
|
||||
status_channel: Channel<LiveStatusMessage>,
|
||||
) -> Result<StartLiveTranscriptionResponse, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let _lifecycle = live_state.lifecycle.lock().await;
|
||||
{
|
||||
let running = live_state.running.lock().unwrap();
|
||||
@@ -586,10 +590,12 @@ pub async fn start_live_transcription_session(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_live_transcription_session(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
live_state: tauri::State<'_, LiveTranscriptionState>,
|
||||
session_id: u64,
|
||||
) -> Result<StopLiveTranscriptionResponse, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let _lifecycle = live_state.lifecycle.lock().await;
|
||||
let running = live_state.running.lock().unwrap().take();
|
||||
let Some(running) = running else {
|
||||
@@ -854,6 +860,7 @@ fn maybe_dispatch_chunk(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_inference(
|
||||
inflight: &mut Option<InferenceTask>,
|
||||
result_listener_lost: &mut bool,
|
||||
@@ -863,6 +870,7 @@ fn poll_inference(
|
||||
dictionary_terms: &[String],
|
||||
result_channel: &Channel<LiveResultMessage>,
|
||||
status_channel: &Channel<LiveStatusMessage>,
|
||||
stop_flag: &Arc<AtomicBool>,
|
||||
) -> Result<Option<bool>, String> {
|
||||
let Some(task) = inflight else {
|
||||
return Ok(None);
|
||||
@@ -913,6 +921,7 @@ fn poll_inference(
|
||||
result_channel,
|
||||
status_channel,
|
||||
result_listener_lost,
|
||||
stop_flag,
|
||||
&result_message,
|
||||
);
|
||||
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
||||
@@ -963,6 +972,7 @@ fn emit_live_result(
|
||||
result_channel: &Channel<LiveResultMessage>,
|
||||
status_channel: &Channel<LiveStatusMessage>,
|
||||
result_listener_lost: &mut bool,
|
||||
stop_flag: &Arc<AtomicBool>,
|
||||
result_message: &LiveResultMessage,
|
||||
) -> bool {
|
||||
if *result_listener_lost {
|
||||
@@ -977,10 +987,25 @@ fn emit_live_result(
|
||||
"[live] session {}: result listener unavailable on chunk {}: {}; continuing without live updates",
|
||||
result_message.session_id, result_message.chunk_id, err
|
||||
);
|
||||
let _ = status_channel.send(LiveStatusMessage::Warning {
|
||||
// If the warning send also fails, the entire frontend channel
|
||||
// pair is dead — almost certainly the user closed the app
|
||||
// window without calling stop_live_transcription_session.
|
||||
// Self-assert stop_flag so the inference worker drains and
|
||||
// exits cleanly instead of polling every 10 ms forever, which
|
||||
// otherwise would burn CPU + GPU memory and keep the WAV
|
||||
// writer file handle open until the process dies.
|
||||
let warn_send = status_channel.send(LiveStatusMessage::Warning {
|
||||
session_id: result_message.session_id,
|
||||
message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(),
|
||||
});
|
||||
if warn_send.is_err() {
|
||||
eprintln!(
|
||||
"[live] session {}: status channel also unavailable; \
|
||||
self-asserting stop_flag so the worker exits",
|
||||
result_message.session_id
|
||||
);
|
||||
stop_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1086,11 +1111,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 +1181,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()
|
||||
@@ -1527,6 +1551,7 @@ mod tests {
|
||||
};
|
||||
let mut recent_segments = Vec::new();
|
||||
let mut result_listener_lost = false;
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let (tx1, rx1) = std::sync::mpsc::channel();
|
||||
tx1.send(Ok(kon_transcription::TimedTranscript {
|
||||
@@ -1555,6 +1580,7 @@ mod tests {
|
||||
&[],
|
||||
&result_channel,
|
||||
&status_channel,
|
||||
&stop_flag,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1596,12 +1622,21 @@ mod tests {
|
||||
&[],
|
||||
&result_channel,
|
||||
&status_channel,
|
||||
&stop_flag,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second, Some(true));
|
||||
assert!(inflight.is_none());
|
||||
assert_eq!(recent_segments.len(), 2);
|
||||
// Status channel still alive in this scenario, so stop_flag must
|
||||
// NOT have been auto-asserted — the worker keeps running so the
|
||||
// user can still call stop_live_transcription_session and receive
|
||||
// the Finished status.
|
||||
assert!(
|
||||
!stop_flag.load(Ordering::Relaxed),
|
||||
"stop_flag should stay false when the status channel is alive"
|
||||
);
|
||||
assert_eq!(
|
||||
statuses.lock().unwrap().len(),
|
||||
warning_count_after_first,
|
||||
@@ -1609,6 +1644,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_result_and_status_channels_self_assert_stop_flag() {
|
||||
// Both channels error on every send: the frontend has gone away
|
||||
// entirely (e.g., the user closed the main window without a
|
||||
// graceful stop). The worker must self-stop via stop_flag so it
|
||||
// doesn't burn CPU + GPU for an indefinitely-long session that
|
||||
// nobody is observing.
|
||||
let result_channel: Channel<LiveResultMessage> =
|
||||
Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
|
||||
let status_channel: Channel<LiveStatusMessage> =
|
||||
Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let mut result_listener_lost = false;
|
||||
|
||||
let message = LiveResultMessage {
|
||||
session_id: 99,
|
||||
chunk_id: 1,
|
||||
chunk_start_secs: 0.0,
|
||||
duration: 0.5,
|
||||
language: "en".into(),
|
||||
inference_ms: 10,
|
||||
segments: vec![],
|
||||
raw_text: String::new(),
|
||||
};
|
||||
|
||||
let delivered = emit_live_result(
|
||||
&result_channel,
|
||||
&status_channel,
|
||||
&mut result_listener_lost,
|
||||
&stop_flag,
|
||||
&message,
|
||||
);
|
||||
|
||||
assert!(!delivered, "send must report not delivered when result_channel errors");
|
||||
assert!(result_listener_lost, "result_listener_lost must be set");
|
||||
assert!(
|
||||
stop_flag.load(Ordering::Relaxed),
|
||||
"stop_flag must self-assert when both channels are dead so the worker exits"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_starts_allow_only_one_session_to_claim_the_slot() {
|
||||
let live_state = Arc::new(LiveTranscriptionState::default());
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::power::PowerAssertion;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{llm_cleanup_text, LlmPromptPreset};
|
||||
use kon_core::hardware;
|
||||
use kon_llm::model_manager::{self, model_info};
|
||||
use kon_llm::LlmModelId;
|
||||
use kon_llm::{ContentTags, LlmModelId};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -58,7 +59,12 @@ pub fn check_llm_model(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_llm_model(app: tauri::AppHandle, model_id: String) -> Result<(), String> {
|
||||
pub async fn download_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parse_model_id(model_id)?;
|
||||
let app_clone = app.clone();
|
||||
model_manager::download_model(id, move |done, total| {
|
||||
@@ -83,11 +89,13 @@ pub async fn download_llm_model(app: tauri::AppHandle, model_id: String) -> Resu
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
use_gpu: Option<bool>,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parse_model_id(model_id)?;
|
||||
let path = model_manager::model_path(id);
|
||||
if !path.exists() {
|
||||
@@ -112,12 +120,21 @@ pub async fn load_llm_model(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unload_llm_model(state: State<'_, AppState>) -> Result<(), String> {
|
||||
pub fn unload_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
state.llm_engine.unload().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_llm_model(state: State<'_, AppState>, model_id: String) -> Result<(), String> {
|
||||
pub fn delete_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parse_model_id(model_id)?;
|
||||
if state.llm_engine.loaded_model_id().as_deref() == Some(id.as_str()) {
|
||||
state.llm_engine.unload().map_err(|e| e.to_string())?;
|
||||
@@ -168,9 +185,11 @@ pub struct LlmTestResult {
|
||||
/// LLMs, adapted to Kon's local stack.
|
||||
#[tauri::command]
|
||||
pub async fn test_llm_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
) -> Result<LlmTestResult, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parse_model_id(model_id)?;
|
||||
let info = model_info(id);
|
||||
let path = model_manager::model_path(id);
|
||||
@@ -342,11 +361,13 @@ mod tests {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_transcript_text_cmd(
|
||||
window: tauri::WebviewWindow,
|
||||
state: State<'_, AppState>,
|
||||
transcript: String,
|
||||
profile_id: Option<String>,
|
||||
preset: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
let profile_terms: Vec<String> =
|
||||
@@ -377,3 +398,26 @@ pub async fn cleanup_transcript_text_cmd(
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Phase 9 LLM-powered content tags. On-demand from the History page;
|
||||
/// never auto-runs. Heavy work (LlmEngine::extract_content_tags is
|
||||
/// synchronous llama-cpp inference) is wrapped in spawn_blocking so it
|
||||
/// does not stall the Tauri runtime, with the same App-Nap power
|
||||
/// assertion the other LLM commands use.
|
||||
#[tauri::command]
|
||||
pub async fn extract_content_tags_cmd(
|
||||
state: State<'_, AppState>,
|
||||
transcript: String,
|
||||
) -> Result<ContentTags, String> {
|
||||
if !state.llm_engine.is_loaded() {
|
||||
return Err("LLM not loaded. Download an AI model in Settings.".to_string());
|
||||
}
|
||||
let engine = state.llm_engine.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _power_guard = PowerAssertion::begin("kon LLM content-tag extraction");
|
||||
engine.extract_content_tags(&transcript)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -5,13 +5,46 @@
|
||||
//! that reminds the user to start recording with their hotkey. We do not
|
||||
//! start recording from this signal — the user decides.
|
||||
|
||||
use kon_core::process_watch;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use kon_core::process_watch::{self, ProcessLister};
|
||||
|
||||
/// Tauri-managed state for the meeting poller. Holds a long-lived
|
||||
/// `ProcessLister` so each poll refreshes the existing `sysinfo::System`
|
||||
/// in place instead of allocating a fresh one — the previous implementation
|
||||
/// rebuilt the process table from scratch every 15 s.
|
||||
pub struct MeetingState {
|
||||
lister: Mutex<ProcessLister>,
|
||||
}
|
||||
|
||||
impl MeetingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
lister: Mutex::new(ProcessLister::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MeetingState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_meeting_processes(patterns: Vec<String>) -> Result<Vec<String>, String> {
|
||||
pub fn detect_meeting_processes(
|
||||
state: tauri::State<'_, MeetingState>,
|
||||
patterns: Vec<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if patterns.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let processes = process_watch::list_running_process_names();
|
||||
let processes = {
|
||||
let mut lister = state
|
||||
.lister
|
||||
.lock()
|
||||
.map_err(|e| format!("MeetingState lister poisoned: {e}"))?;
|
||||
lister.snapshot()
|
||||
};
|
||||
Ok(process_watch::match_meeting_patterns(&processes, &patterns))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
pub mod audio;
|
||||
pub mod clipboard;
|
||||
pub mod diagnostics;
|
||||
pub mod feedback;
|
||||
pub mod fs;
|
||||
pub mod hardware;
|
||||
pub mod hotkey;
|
||||
pub mod intentions;
|
||||
pub mod live;
|
||||
pub mod llm;
|
||||
pub mod meeting;
|
||||
pub mod models;
|
||||
pub mod nudges;
|
||||
pub mod paste;
|
||||
pub mod power;
|
||||
pub mod profiles;
|
||||
pub mod rituals;
|
||||
pub mod security;
|
||||
pub mod tasks;
|
||||
pub mod transcription;
|
||||
pub mod transcripts;
|
||||
pub mod tts;
|
||||
pub mod update;
|
||||
pub mod windows;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use kon_core::hardware::{self, CpuFeatures};
|
||||
@@ -221,6 +222,16 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn prewarm_default_model_cmd(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
prewarm_default_model(state.whisper_engine.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeCapabilities {
|
||||
@@ -412,7 +423,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
|
||||
reason: None,
|
||||
};
|
||||
}
|
||||
return ActiveComputeDevice {
|
||||
ActiveComputeDevice {
|
||||
kind: "cpu".into(),
|
||||
label: "CPU (fallback)".into(),
|
||||
reason: Some(
|
||||
@@ -420,7 +431,7 @@ pub fn detect_active_compute_device() -> ActiveComputeDevice {
|
||||
libvulkan1 (Linux) to enable GPU acceleration."
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +542,12 @@ pub fn get_runtime_capabilities(
|
||||
// --- Whisper model commands ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_model(app: tauri::AppHandle, size: String) -> Result<String, String> {
|
||||
pub async fn download_model(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
size: String,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = whisper_model_id(&size);
|
||||
let app_clone = app.clone();
|
||||
model_manager::download(&id, move |progress| {
|
||||
@@ -568,10 +584,12 @@ pub fn list_models() -> Result<Vec<String>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
size: String,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = whisper_model_id(&size);
|
||||
ensure_model_loaded(&state, "whisper", id.as_str(), concurrent).await?;
|
||||
Ok(format!("Model {} loaded", size))
|
||||
@@ -586,9 +604,11 @@ pub fn check_engine(state: tauri::State<AppState>) -> Result<bool, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_parakeet_model(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parakeet_model_id(&name);
|
||||
let app_clone = app.clone();
|
||||
model_manager::download(&id, move |progress| {
|
||||
@@ -620,10 +640,12 @@ pub fn list_parakeet_models() -> Result<Vec<String>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_parakeet_model(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
name: String,
|
||||
concurrent: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let id = parakeet_model_id(&name);
|
||||
ensure_model_loaded(&state, "parakeet", id.as_str(), concurrent).await?;
|
||||
Ok(format!("Parakeet model {} loaded", name))
|
||||
|
||||
63
src-tauri/src/commands/nudges.rs
Normal file
63
src-tauri/src/commands/nudges.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Phase 6 of the feature-complete roadmap: Margot soft-touch nudges.
|
||||
//!
|
||||
//! The nudge bus lives in the frontend (`nudgeBus.svelte.ts`) — it
|
||||
//! owns trigger subscription, suppression rules, and the hourly cap.
|
||||
//! This module is the thin Rust side that:
|
||||
//!
|
||||
//! - Guards delivery to the main window only (secondary windows like
|
||||
//! the task float can't fire notifications at the user — they're
|
||||
//! already beside the user's attention).
|
||||
//! - Delegates to `tauri-plugin-notification` for cross-platform OS
|
||||
//! delivery. The plugin handles the macOS Notification Center /
|
||||
//! Linux org.freedesktop.Notifications / Windows toast plumbing.
|
||||
//!
|
||||
//! Permission handling happens on the JS side via the plugin's
|
||||
//! `isPermissionGranted` / `requestPermission` API — before the first
|
||||
//! `deliver_nudge` call, the nudge bus prompts the user. If denied,
|
||||
//! subsequent calls return an error that the bus logs and swallows.
|
||||
//!
|
||||
//! No persistence for v1. Cooldown state is ephemeral; the roadmap's
|
||||
//! nudge-audit table is deferred until a real need emerges.
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
use crate::commands::security::ensure_main_window;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeliverNudgeInput {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Deliver a single OS notification. Does not apply suppression
|
||||
/// logic — the frontend nudge bus is responsible for cadence and
|
||||
/// cooldown, so this command is a blunt "push it now" primitive.
|
||||
///
|
||||
/// Returns an error if the notification plugin refuses the call
|
||||
/// (typically because permission was denied by the user at the OS
|
||||
/// level). The nudge bus logs and swallows these — nudges are
|
||||
/// fire-and-forget and must never surface errors to the user.
|
||||
#[tauri::command]
|
||||
pub fn deliver_nudge(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
input: DeliverNudgeInput,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
|
||||
let title = input.title.trim();
|
||||
let body = input.body.trim();
|
||||
if title.is_empty() && body.is_empty() {
|
||||
// A blank nudge is worse than no nudge — stay quiet.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(if title.is_empty() { "Corbie" } else { title })
|
||||
.body(body)
|
||||
.show()
|
||||
.map_err(|e| format!("notification delivery failed: {e}"))
|
||||
}
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
43
src-tauri/src/commands/rituals.rs
Normal file
43
src-tauri/src/commands/rituals.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Phase 5 of the feature-complete roadmap: start- and shutdown-rituals.
|
||||
//!
|
||||
//! Layer-1 scope is narrow. The frontend owns rendering and logic; this
|
||||
//! module only persists the "last date the morning triage modal was
|
||||
//! shown" sentinel so the modal can refuse to re-prompt on the same
|
||||
//! calendar day. Task queries re-use `list_tasks_cmd` with client-side
|
||||
//! filtering rather than adding a second query path.
|
||||
//!
|
||||
//! Stored under the existing SQLite settings table via
|
||||
//! `kon_storage::{get_setting, set_setting}` — same bag as
|
||||
//! `kon_preferences`.
|
||||
|
||||
use kon_storage::{get_setting, set_setting};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
const LAST_TRIAGE_KEY: &str = "kon_morning_triage_last_shown";
|
||||
|
||||
/// Returns the YYYY-MM-DD date string stored on the last successful
|
||||
/// morning triage dismissal, or `None` if the user has never been
|
||||
/// shown the modal.
|
||||
#[tauri::command]
|
||||
pub async fn get_last_morning_triage(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
get_setting(&state.db, LAST_TRIAGE_KEY)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Records that the morning triage modal was shown (and either skipped
|
||||
/// or completed) on `date`. Caller is responsible for passing a valid
|
||||
/// YYYY-MM-DD string in the user's local timezone — Rust deliberately
|
||||
/// stays timezone-agnostic here so the frontend retains control.
|
||||
#[tauri::command]
|
||||
pub async fn mark_morning_triage_shown(
|
||||
state: tauri::State<'_, AppState>,
|
||||
date: String,
|
||||
) -> Result<(), String> {
|
||||
set_setting(&state.db, LAST_TRIAGE_KEY, &date)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
30
src-tauri/src/commands/security.rs
Normal file
30
src-tauri/src/commands/security.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
pub fn ensure_main_window(window: &tauri::WebviewWindow) -> Result<(), String> {
|
||||
ensure_main_window_label(window.label())
|
||||
}
|
||||
|
||||
pub fn ensure_main_window_label(label: &str) -> Result<(), String> {
|
||||
if label == "main" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"This command is only available from the main window (got {label})."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ensure_main_window_label;
|
||||
|
||||
#[test]
|
||||
fn accepts_main_window() {
|
||||
assert!(ensure_main_window_label("main").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_secondary_windows() {
|
||||
for label in ["tasks-float", "transcript-viewer", "transcription-preview"] {
|
||||
assert!(ensure_main_window_label(label).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,16 @@ 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_recent_completions as db_list_recent_completions, list_subtasks as db_list_subtasks,
|
||||
list_tasks as db_list_tasks, set_task_energy as db_set_task_energy,
|
||||
uncomplete_task as db_uncomplete_task, update_task as db_update_task, DailyCompletionCount,
|
||||
FeedbackRow, FeedbackTargetType, TaskRow,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
@@ -31,6 +35,7 @@ pub struct TaskDto {
|
||||
pub created_at: String,
|
||||
pub source_transcript_id: Option<String>,
|
||||
pub parent_task_id: Option<String>,
|
||||
pub energy: Option<String>,
|
||||
}
|
||||
|
||||
impl From<TaskRow> for TaskDto {
|
||||
@@ -47,10 +52,27 @@ impl From<TaskRow> for TaskDto {
|
||||
created_at: r.created_at,
|
||||
source_transcript_id: r.source_transcript_id,
|
||||
parent_task_id: r.parent_task_id,
|
||||
energy: r.energy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepted energy tag values. Kept as a const so frontend and storage
|
||||
/// validate against the same list. Migration v11 enforces the same
|
||||
/// set via a CHECK constraint.
|
||||
const ENERGY_LEVELS: &[&str] = &["high", "medium", "brain_dead"];
|
||||
|
||||
fn validate_energy(raw: Option<&str>) -> Result<Option<&str>, String> {
|
||||
match raw {
|
||||
None => Ok(None),
|
||||
Some(s) if ENERGY_LEVELS.contains(&s) => Ok(Some(s)),
|
||||
Some(other) => Err(format!(
|
||||
"energy must be one of {:?} or null, got {:?}",
|
||||
ENERGY_LEVELS, other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTaskRequest {
|
||||
@@ -63,6 +85,8 @@ pub struct CreateTaskRequest {
|
||||
pub list_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub effort: Option<String>,
|
||||
#[serde(default)]
|
||||
pub energy: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -70,6 +94,7 @@ pub async fn create_task_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: CreateTaskRequest,
|
||||
) -> Result<TaskDto, String> {
|
||||
let energy = validate_energy(request.energy.as_deref())?;
|
||||
db_insert_task(
|
||||
&state.db,
|
||||
&request.id,
|
||||
@@ -78,6 +103,7 @@ pub async fn create_task_cmd(
|
||||
request.source_transcript_id.as_deref(),
|
||||
request.list_id.as_deref(),
|
||||
request.effort.as_deref(),
|
||||
energy,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -166,22 +192,138 @@ pub async fn uncomplete_task_cmd(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Phase 3: set or clear the `energy` tag on a task. Dedicated command
|
||||
/// rather than a field on `update_task_cmd` because the existing update
|
||||
/// path uses `COALESCE` semantics where `None` means "preserve" — which
|
||||
/// makes clearing the tag impossible. This command always writes exactly
|
||||
/// what you send, including `None` to explicitly clear.
|
||||
#[tauri::command]
|
||||
pub async fn set_task_energy_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
energy: Option<String>,
|
||||
) -> Result<TaskDto, String> {
|
||||
let validated = validate_energy(energy.as_deref())?;
|
||||
let row = db_set_task_energy(&state.db, &id, validated)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(TaskDto::from(row))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Malformed `context_json` is logged rather than silently dropped so
|
||||
/// data-integrity regressions surface instead of disappearing.
|
||||
fn to_llm_examples(rows: Vec<FeedbackRow>) -> Vec<LlmFeedbackExample> {
|
||||
rows.into_iter()
|
||||
.filter_map(|r| {
|
||||
let raw = r.context_json.as_deref().unwrap_or("{}");
|
||||
let ctx: serde_json::Value = match serde_json::from_str(raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[feedback] skipping row id={} with malformed context_json: {e}",
|
||||
r.id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
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()
|
||||
}
|
||||
|
||||
/// Rough character budget for the few-shot block. Qwen3's tokenizer
|
||||
/// averages ~3.5 chars per token in English, so 2000 chars is ~570
|
||||
/// tokens — well inside the 64-token reserve + response-token gap
|
||||
/// against the 8192-token context cap (see `LlmEngine::generate`).
|
||||
///
|
||||
/// Exceed this and we drop the oldest examples first. Rationale: the
|
||||
/// retrieval already orders most-recent-first, and the most recent
|
||||
/// correction is usually the one carrying the user's live preference.
|
||||
const FEW_SHOT_CHAR_BUDGET: usize = 2000;
|
||||
|
||||
fn example_char_cost(ex: &LlmFeedbackExample) -> usize {
|
||||
// Matches the render path in `prompts::render_feedback_exemplar`:
|
||||
// "Input: {input}\nGood output: {good}". Prefix strings + newlines
|
||||
// + the two bodies. Slight overestimate to leave headroom.
|
||||
let good_len = ex
|
||||
.corrected_output
|
||||
.as_deref()
|
||||
.or(ex.original_output.as_deref())
|
||||
.map(str::len)
|
||||
.unwrap_or(0);
|
||||
ex.input.len() + good_len + 24
|
||||
}
|
||||
|
||||
fn trim_to_budget(mut examples: Vec<LlmFeedbackExample>) -> Vec<LlmFeedbackExample> {
|
||||
let mut running = 0usize;
|
||||
let mut kept = Vec::with_capacity(examples.len());
|
||||
for ex in examples.drain(..) {
|
||||
let cost = example_char_cost(&ex);
|
||||
if running + cost > FEW_SHOT_CHAR_BUDGET {
|
||||
break;
|
||||
}
|
||||
running += cost;
|
||||
kept.push(ex);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn decompose_and_store(
|
||||
state: tauri::State<'_, AppState>,
|
||||
parent_task_id: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<Vec<TaskDto>, String> {
|
||||
let parent = db_get_task(&state.db, &parent_task_id)
|
||||
.await
|
||||
.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 AND at a char budget to keep the prompt
|
||||
// under token budget regardless of how much feedback has been
|
||||
// captured, and scope by profile so per-profile styles do not
|
||||
// leak into each other.
|
||||
let examples = db_list_feedback_examples(
|
||||
&state.db,
|
||||
FeedbackTargetType::MicroStep,
|
||||
5,
|
||||
0,
|
||||
profile_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(to_llm_examples)
|
||||
.map(trim_to_budget)
|
||||
.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 {
|
||||
@@ -204,9 +346,22 @@ pub async fn decompose_and_store(
|
||||
pub async fn extract_tasks_from_transcript_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
transcript: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let examples = db_list_feedback_examples(
|
||||
&state.db,
|
||||
FeedbackTargetType::TaskExtraction,
|
||||
5,
|
||||
0,
|
||||
profile_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(to_llm_examples)
|
||||
.map(trim_to_budget)
|
||||
.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())
|
||||
@@ -232,3 +387,16 @@ pub async fn complete_subtask_cmd(
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Phase 8: daily completion counts for the Tasks-page badge and the
|
||||
/// 7-day momentum sparkline. Returns a fixed-length oldest-first
|
||||
/// series. Empty days are explicit zeros.
|
||||
#[tauri::command]
|
||||
pub async fn list_recent_completions_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
days: u32,
|
||||
) -> Result<Vec<DailyCompletionCount>, String> {
|
||||
db_list_recent_completions(&state.db, days)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use tauri::Emitter;
|
||||
|
||||
use crate::commands::build_initial_prompt;
|
||||
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use crate::AppState;
|
||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||
use kon_core::constants::WHISPER_SAMPLE_RATE;
|
||||
@@ -20,6 +21,7 @@ const PARAKEET_CHUNK_OVERLAP_SECS: usize = 1;
|
||||
const FILE_CHUNK_THRESHOLD_SECS: usize = 8 * 60;
|
||||
const FILE_CHUNK_SECS: usize = 3 * 60;
|
||||
const FILE_CHUNK_OVERLAP_SECS: usize = 2;
|
||||
const MAX_FILE_TRANSCRIPTION_SECS: f64 = 2.0 * 60.0 * 60.0;
|
||||
|
||||
struct ChunkingStrategy {
|
||||
chunk_samples: usize,
|
||||
@@ -138,6 +140,7 @@ fn transcribe_samples_sync(
|
||||
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
@@ -150,6 +153,7 @@ pub async fn transcribe_pcm(
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
|
||||
@@ -230,6 +234,7 @@ fn join_segment_text(segments: &[Segment]) -> String {
|
||||
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_file(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
path: String,
|
||||
engine: Option<String>,
|
||||
@@ -242,6 +247,7 @@ pub async fn transcribe_file(
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
|
||||
@@ -275,9 +281,24 @@ pub async fn transcribe_file(
|
||||
),
|
||||
};
|
||||
let engine_name_for_worker = engine_name.clone();
|
||||
let path_for_probe = Path::new(&path);
|
||||
if let Some(duration_secs) =
|
||||
kon_audio::probe_audio_duration_secs(path_for_probe).map_err(|e| e.to_string())?
|
||||
{
|
||||
if duration_secs > MAX_FILE_TRANSCRIPTION_SECS {
|
||||
return Err(format!(
|
||||
"File is {:.1} hours long. Kon imports up to 2 hours at a time.",
|
||||
duration_secs / 3600.0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let timed = tokio::task::spawn_blocking(move || {
|
||||
let audio = kon_audio::decode_audio_file(Path::new(&path)).map_err(|e| e.to_string())?;
|
||||
let audio = kon_audio::decode_audio_file_limited(
|
||||
Path::new(&path),
|
||||
Some(MAX_FILE_TRANSCRIPTION_SECS),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resampled = kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
|
||||
transcribe_samples_sync(
|
||||
engine,
|
||||
@@ -319,6 +340,7 @@ pub async fn transcribe_file(
|
||||
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm_parakeet(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
@@ -329,6 +351,7 @@ pub async fn transcribe_pcm_parakeet(
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct TranscriptDto {
|
||||
pub template: String,
|
||||
pub language: String,
|
||||
pub segments_json: String,
|
||||
/// Phase 9 LLM-generated content tags ("topic:...", "intent:...").
|
||||
pub llm_tags: String,
|
||||
}
|
||||
|
||||
impl From<TranscriptRow> for TranscriptDto {
|
||||
@@ -68,6 +70,7 @@ impl From<TranscriptRow> for TranscriptDto {
|
||||
template: r.template,
|
||||
language: r.language,
|
||||
segments_json: r.segments_json,
|
||||
llm_tags: r.llm_tags,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +224,11 @@ pub struct UpdateTranscriptMetaRequest {
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub segments_json: Option<String>,
|
||||
/// Phase 9 LLM content tags. Same comma-joined string convention as
|
||||
/// `manual_tags`. Pass `None` to leave unchanged; pass `Some("")` to
|
||||
/// explicitly clear.
|
||||
#[serde(default)]
|
||||
pub llm_tags: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -237,6 +245,7 @@ pub async fn update_transcript_meta_cmd(
|
||||
patch.template.as_deref(),
|
||||
patch.language.as_deref(),
|
||||
patch.segments_json.as_deref(),
|
||||
patch.llm_tags.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
444
src-tauri/src/commands/tts.rs
Normal file
444
src-tauri/src/commands/tts.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
//! Phase 4 of the feature-complete roadmap: platform-native Read Page Aloud.
|
||||
//!
|
||||
//! Layer-1 scope: shell out to the OS's built-in TTS binary, return
|
||||
//! immediately (non-blocking), track the spawned child so `tts_stop`
|
||||
//! can cancel in-flight speech. No SSML, no pause/resume, no cloud
|
||||
//! voices. User text is never interpolated into a shell string —
|
||||
//! every platform passes the text via argv (or, on Windows, inside a
|
||||
//! PowerShell here-string delivered through `-EncodedCommand`).
|
||||
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Active synth child process, if any. On Linux `spd-say` returns
|
||||
/// immediately so the slot is usually empty; macOS `say` and Windows
|
||||
/// PowerShell speak synchronously, so we store the handle to kill
|
||||
/// on `tts_stop`.
|
||||
#[derive(Default)]
|
||||
pub struct TtsState {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl TtsState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TtsVoice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
/// Clamp user-supplied rate into the app's supported range.
|
||||
/// `0.5` = half speed, `1.0` = normal, `2.0` = double speed.
|
||||
pub fn clamp_rate(rate: f32) -> f32 {
|
||||
if !rate.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
rate.clamp(0.5, 2.0)
|
||||
}
|
||||
|
||||
// ---------- Linux (spd-say, espeak-ng fallback) ----------
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn spd_rate(rate: f32) -> i32 {
|
||||
// spd-say -r takes -100..=100. 1.0 -> 0 (default rate). We map the
|
||||
// 1.0..=2.0 half linearly to 0..=100, and the 0.5..=1.0 half
|
||||
// linearly to -50..=0. Asymmetric but simple; users who want
|
||||
// slower playback than -50 can change the system synth rate in
|
||||
// their accessibility settings.
|
||||
let r = clamp_rate(rate);
|
||||
((r - 1.0) * 100.0).round().clamp(-100.0, 100.0) as i32
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn espeak_rate(rate: f32) -> u32 {
|
||||
// espeak-ng -s: words per minute (default 175, min 80, max 450).
|
||||
let r = clamp_rate(rate);
|
||||
((r * 175.0).round() as i32).clamp(80, 450) as u32
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn spawn_linux(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
|
||||
let mut cmd = Command::new("spd-say");
|
||||
cmd.arg("-r").arg(spd_rate(rate).to_string());
|
||||
if let Some(v) = voice {
|
||||
cmd.arg("-t").arg(v);
|
||||
}
|
||||
cmd.arg("--").arg(text);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
match cmd.spawn() {
|
||||
// spd-say is non-blocking — the child exits before speech
|
||||
// finishes, so there's nothing useful to track.
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let mut fb = Command::new("espeak-ng");
|
||||
fb.arg("-s").arg(espeak_rate(rate).to_string());
|
||||
if let Some(v) = voice {
|
||||
fb.arg("-v").arg(v);
|
||||
}
|
||||
fb.arg("--").arg(text);
|
||||
fb.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
fb.spawn()
|
||||
.map(Some)
|
||||
.map_err(|e| format!("neither spd-say nor espeak-ng is available: {e}"))
|
||||
}
|
||||
Err(e) => Err(format!("failed to spawn spd-say: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn stop_linux() {
|
||||
// Cancels all active spd-say messages for this user. Silent
|
||||
// failure is fine — if spd-say isn't installed there's nothing
|
||||
// to cancel, and any tracked espeak-ng child is killed separately.
|
||||
let _ = Command::new("spd-say")
|
||||
.arg("-S")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
// ---------- macOS (say) ----------
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn say_rate(rate: f32) -> u32 {
|
||||
// `say -r`: words per minute. 180 wpm is roughly the default; clamp
|
||||
// to a sane range so the slider can't produce an unreadable rate.
|
||||
let r = clamp_rate(rate);
|
||||
((r * 180.0).round() as i32).clamp(90, 500) as u32
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn spawn_macos(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
|
||||
let mut cmd = Command::new("say");
|
||||
cmd.arg("-r").arg(say_rate(rate).to_string());
|
||||
if let Some(v) = voice {
|
||||
cmd.arg("-v").arg(v);
|
||||
}
|
||||
cmd.arg("--").arg(text);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
cmd.spawn()
|
||||
.map(Some)
|
||||
.map_err(|e| format!("failed to spawn `say`: {e}"))
|
||||
}
|
||||
|
||||
// ---------- Windows (PowerShell System.Speech) ----------
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn win_rate(rate: f32) -> i32 {
|
||||
// SpeechSynthesizer.Rate is an integer in -10..=10.
|
||||
let r = clamp_rate(rate);
|
||||
((r - 1.0) * 10.0).round().clamp(-10.0, 10.0) as i32
|
||||
}
|
||||
|
||||
/// A PowerShell single-quoted here-string terminates on `'@` at the
|
||||
/// start of a line. Neutralise any such sequence inside user text so
|
||||
/// the here-string always closes where we intend.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn escape_ps_herestring(text: &str) -> String {
|
||||
text.replace("'@", "' @")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn spawn_windows(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
let rate_int = win_rate(rate);
|
||||
let safe_text = escape_ps_herestring(text);
|
||||
let select = match voice {
|
||||
// Regular single-quoted string (not here-string): doubled
|
||||
// single quotes are the escape for a literal `'`.
|
||||
Some(v) => format!("$s.SelectVoice('{}')", v.replace('\'', "''")),
|
||||
None => String::new(),
|
||||
};
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Speech;\n\
|
||||
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;\n\
|
||||
$s.Rate = {rate_int};\n\
|
||||
{select};\n\
|
||||
$s.Speak(@'\n\
|
||||
{safe_text}\n\
|
||||
'@)"
|
||||
);
|
||||
let utf16: Vec<u8> = script
|
||||
.encode_utf16()
|
||||
.flat_map(|u| u.to_le_bytes())
|
||||
.collect();
|
||||
let encoded = STANDARD.encode(&utf16);
|
||||
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
cmd.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
cmd.spawn()
|
||||
.map(Some)
|
||||
.map_err(|e| format!("failed to spawn powershell: {e}"))
|
||||
}
|
||||
|
||||
// ---------- Voice listing ----------
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||
// spd-say's voice set depends on the active synth module and the
|
||||
// list format isn't stable across distributions. For Phase 4 the
|
||||
// picker renders "System default" only, which matches what
|
||||
// `tts_speak` without a voice argument does anyway.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||
let out = Command::new("say")
|
||||
.arg("-v")
|
||||
.arg("?")
|
||||
.output()
|
||||
.map_err(|e| format!("failed to query voices: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("`say -v ?` exited with status {}", out.status));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
Ok(parse_macos_voices(&stdout))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn parse_macos_voices(raw: &str) -> Vec<TtsVoice> {
|
||||
raw.lines()
|
||||
.filter_map(|line| {
|
||||
let (prefix, _sample) = line.split_once('#')?;
|
||||
let mut parts = prefix.split_whitespace();
|
||||
let name = parts.next()?.to_string();
|
||||
let locale = parts.next().map(str::to_string);
|
||||
Some(TtsVoice {
|
||||
id: name.clone(),
|
||||
name,
|
||||
language: locale,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
let script = "Add-Type -AssemblyName System.Speech;\n\
|
||||
(New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |\n\
|
||||
ForEach-Object { $_.VoiceInfo } |\n\
|
||||
ForEach-Object { @{ Name = $_.Name; Culture = $_.Culture.Name } } |\n\
|
||||
ConvertTo-Json -Compress";
|
||||
let utf16: Vec<u8> = script
|
||||
.encode_utf16()
|
||||
.flat_map(|u| u.to_le_bytes())
|
||||
.collect();
|
||||
let encoded = STANDARD.encode(&utf16);
|
||||
let out = Command::new("powershell.exe")
|
||||
.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded])
|
||||
.output()
|
||||
.map_err(|e| format!("failed to query voices: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"PowerShell voice-list exited with status {}",
|
||||
out.status
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(trimmed).map_err(|e| format!("voice-list JSON parse failed: {e}"))?;
|
||||
// ConvertTo-Json emits a bare object for a single item and an
|
||||
// array otherwise; normalise to always-array.
|
||||
let items: Vec<serde_json::Value> = match parsed {
|
||||
serde_json::Value::Array(a) => a,
|
||||
v => vec![v],
|
||||
};
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.filter_map(|v| {
|
||||
let name = v.get("Name")?.as_str()?.to_string();
|
||||
let culture = v
|
||||
.get("Culture")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(str::to_string);
|
||||
Some(TtsVoice {
|
||||
id: name.clone(),
|
||||
name,
|
||||
language: culture,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// Non-desktop fallback. Android's native TextToSpeech API would need a
|
||||
// JNI bridge — out of scope for v0.1-android. The frontend already hides
|
||||
// the Read Page Aloud button behind a runtime detection; this stub keeps
|
||||
// the command surface consistent so a stray invoke surfaces a clear
|
||||
// error rather than panicking on an unbound `list_voices_impl`.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
// ---------- Tauri commands ----------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn tts_speak(
|
||||
state: tauri::State<'_, TtsState>,
|
||||
text: String,
|
||||
rate: f32,
|
||||
voice: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Cut any in-flight speech so a second tap starts cleanly rather
|
||||
// than queueing on top.
|
||||
kill_child(&state);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let spawned = spawn_linux(trimmed, rate, voice.as_deref())?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let spawned = spawn_macos(trimmed, rate, voice.as_deref())?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let spawned = spawn_windows(trimmed, rate, voice.as_deref())?;
|
||||
// No bundled TTS shim for non-desktop targets. Android has its own
|
||||
// TextToSpeech APIs that would have to be reached over JNI; out of
|
||||
// scope for v0.1-android. Mirrors the paste.rs not-implemented
|
||||
// fallback so the command compiles cleanly across all targets and
|
||||
// surfaces a clear error if the frontend tries to invoke it.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (state, rate, voice);
|
||||
return Err("TTS not implemented on this platform".into());
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
if let Some(c) = spawned {
|
||||
if let Ok(mut guard) = state.child.lock() {
|
||||
*guard = Some(c);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn tts_stop(state: tauri::State<'_, TtsState>) -> Result<(), String> {
|
||||
kill_child(&state);
|
||||
#[cfg(target_os = "linux")]
|
||||
stop_linux();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_child(state: &TtsState) {
|
||||
if let Ok(mut guard) = state.child.lock() {
|
||||
if let Some(mut c) = guard.take() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn tts_list_voices() -> Result<Vec<TtsVoice>, String> {
|
||||
list_voices_impl()
|
||||
}
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clamp_rate_handles_nan() {
|
||||
assert_eq!(clamp_rate(f32::NAN), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_rate_bounds() {
|
||||
assert_eq!(clamp_rate(0.1), 0.5);
|
||||
assert_eq!(clamp_rate(0.5), 0.5);
|
||||
assert_eq!(clamp_rate(1.0), 1.0);
|
||||
assert_eq!(clamp_rate(2.0), 2.0);
|
||||
assert_eq!(clamp_rate(3.0), 2.0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn spd_rate_hits_anchors() {
|
||||
assert_eq!(spd_rate(1.0), 0);
|
||||
assert_eq!(spd_rate(2.0), 100);
|
||||
assert_eq!(spd_rate(1.5), 50);
|
||||
// 0.5 is only -50 with the simple linear slope — the asymmetry
|
||||
// is documented in the `spd_rate` doc comment.
|
||||
assert_eq!(spd_rate(0.5), -50);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn espeak_rate_stays_in_range() {
|
||||
assert_eq!(espeak_rate(1.0), 175);
|
||||
assert_eq!(espeak_rate(2.0), 350);
|
||||
// 0.5 * 175 = 87.5 → rounds to 88 → still ≥ 80 floor.
|
||||
assert_eq!(espeak_rate(0.5), 88);
|
||||
// NaN → clamp_rate returns 1.0 → rate maps to 175.
|
||||
assert_eq!(espeak_rate(f32::NAN), 175);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn say_rate_maps() {
|
||||
assert_eq!(say_rate(1.0), 180);
|
||||
assert_eq!(say_rate(2.0), 360);
|
||||
assert_eq!(say_rate(0.5), 90);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn win_rate_bounds() {
|
||||
assert_eq!(win_rate(0.5), -5);
|
||||
assert_eq!(win_rate(1.0), 0);
|
||||
assert_eq!(win_rate(2.0), 10);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ps_herestring_terminator_is_broken() {
|
||||
let input = "hello\n'@ evil";
|
||||
let out = escape_ps_herestring(input);
|
||||
assert!(!out.contains("'@"));
|
||||
assert!(out.contains("' @"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn parses_macos_voices() {
|
||||
let raw = "Alex en_US # Most people recognize me\n\
|
||||
Fred en_US # I sure like being inside\n";
|
||||
let voices = parse_macos_voices(raw);
|
||||
assert_eq!(voices.len(), 2);
|
||||
assert_eq!(voices[0].name, "Alex");
|
||||
assert_eq!(voices[0].language.as_deref(), Some("en_US"));
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,16 @@
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
use crate::commands::security::ensure_main_window;
|
||||
|
||||
/// Check for an available update. Returns Some(version_string) if one is
|
||||
/// available, None if already up to date, and Err if the check fails.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let update = app
|
||||
.updater()
|
||||
.map_err(|e| format!("Updater not available: {e}"))?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("Update check failed: {e}"))?;
|
||||
|
||||
match update {
|
||||
Some(u) => Ok(Some(u.version.to_string())),
|
||||
None => Ok(None),
|
||||
}
|
||||
pub async fn check_for_update(window: tauri::WebviewWindow) -> Result<Option<String>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Download and stage the update. The app will install it on next launch.
|
||||
#[tauri::command]
|
||||
pub async fn install_update(app: AppHandle) -> Result<(), String> {
|
||||
let update = app
|
||||
.updater()
|
||||
.map_err(|e| format!("Updater not available: {e}"))?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("Update check failed: {e}"))?
|
||||
.ok_or_else(|| "No update available".to_string())?;
|
||||
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|e| format!("Install failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
pub async fn install_update(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
Err("Updates are disabled until release signing is configured.".to_string())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
// Multi-window support is desktop-only. Android Tauri apps run as a
|
||||
// single Activity, so `WebviewWindowBuilder` is not available there.
|
||||
// All four commands below have an Android stub that returns a clear
|
||||
// error so frontend invokes don't panic — the frontend itself is
|
||||
// expected to detect Android via `isAndroid()` and route the previously-
|
||||
// secondary content (preview overlay, transcript viewer, task float)
|
||||
// into routes inside the main window instead.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use crate::PreferencesScript;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_MULTIWINDOW_ERR: &str =
|
||||
"Multi-window is not supported on Android; this command is desktop-only";
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[tauri::command]
|
||||
pub async fn open_task_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
Err(ANDROID_MULTIWINDOW_ERR.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[tauri::command]
|
||||
pub async fn open_preview_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
Err(ANDROID_MULTIWINDOW_ERR.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[tauri::command]
|
||||
pub async fn close_preview_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
Err(ANDROID_MULTIWINDOW_ERR.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[tauri::command]
|
||||
pub async fn open_viewer_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
Err(ANDROID_MULTIWINDOW_ERR.into())
|
||||
}
|
||||
|
||||
/// Open a floating always-on-top task window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[tauri::command]
|
||||
pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("tasks-float") {
|
||||
@@ -46,6 +84,7 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
/// The preview is passive: it subscribes to the `transcription-result`
|
||||
/// event and the cross-window `preview-*` events that DictationPage fires
|
||||
/// as it moves through the phases of a dictation run.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[tauri::command]
|
||||
pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||
@@ -115,6 +154,7 @@ pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
/// Hide the transcription preview window without destroying it so the next
|
||||
/// open is instant. Returns Ok even when no preview window exists.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[tauri::command]
|
||||
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||
@@ -124,6 +164,7 @@ pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Open the transcript viewer window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[tauri::command]
|
||||
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcript-viewer") {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
mod commands;
|
||||
// System tray uses Tauri's `tray-icon` feature which is desktop-only.
|
||||
// Android has no tray surface — drop the module entirely on that target.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod tray;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -9,7 +12,14 @@ use tauri::Manager;
|
||||
|
||||
use kon_core::types::EngineName;
|
||||
use kon_llm::LlmEngine;
|
||||
use kon_storage::{database_path, get_setting, init as init_db, set_setting};
|
||||
use kon_storage::{
|
||||
database_path, get_setting, init as init_db, prune_error_log, set_setting,
|
||||
};
|
||||
|
||||
/// How long to retain `error_log` rows. Pruned once on startup.
|
||||
/// 90 days is long enough to triage "this happened a few weeks ago"
|
||||
/// reports and short enough that the table stays kilobyte-scale.
|
||||
const ERROR_LOG_RETENTION_DAYS: i64 = 90;
|
||||
use kon_transcription::LocalEngine;
|
||||
|
||||
/// Shared app state holding the transcription engines and database pool.
|
||||
@@ -125,17 +135,41 @@ pub fn run() {
|
||||
// Settings → About can attach them. Local only; nothing transmitted.
|
||||
commands::diagnostics::install_panic_hook();
|
||||
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// Phase 6 nudges: OS-native notifications. The plugin exposes
|
||||
// isPermissionGranted / requestPermission / sendNotification on
|
||||
// the JS side; our deliver_nudge wrapper guards those calls
|
||||
// against the nudge-bus suppression rules and restricts
|
||||
// invocation to the main window via ensure_main_window.
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
// Desktop-only plugins. Each is either unsupported on Android
|
||||
// (global-shortcut, autostart) or structurally meaningless on a
|
||||
// single-window mobile app (window-state). Gating here matches the
|
||||
// Cargo.toml `cfg(not(target_os = "android"))` block that drops the
|
||||
// crates entirely on Android targets.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let builder = builder
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// Phase 5 rituals: autostart. The plugin registers JS-facing
|
||||
// commands (isEnabled / enable / disable) that the Settings
|
||||
// toggle and first-run prompt invoke directly — no bespoke
|
||||
// Rust commands needed. `LaunchAgent` is the non-root macOS
|
||||
// install path (per-user, no sudo).
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
// Remember size + position of every window across app restarts.
|
||||
// Without this, secondary windows (preview overlay, task float,
|
||||
// transcript viewer) open at whatever spot the compositor picks,
|
||||
// which feels random. State is persisted per-window-label to
|
||||
// app-data/window-state.json.
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
// Initialise database (blocking in setup — runs once at startup)
|
||||
let db_path = database_path();
|
||||
@@ -144,6 +178,22 @@ pub fn run() {
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
eprintln!("[startup] DB init: {:?}", t0.elapsed());
|
||||
|
||||
// Prune old `error_log` rows so the table doesn't grow unbounded
|
||||
// across months of dogfooding. Best-effort — a prune failure is
|
||||
// not worth blocking startup over.
|
||||
let t_prune = Instant::now();
|
||||
let pruned = tauri::async_runtime::block_on(async {
|
||||
prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await
|
||||
});
|
||||
match pruned {
|
||||
Ok(n) if n > 0 => eprintln!(
|
||||
"[startup] Error log prune: {n} rows removed (>{ERROR_LOG_RETENTION_DAYS}d) in {:?}",
|
||||
t_prune.elapsed()
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(e) => eprintln!("[startup] Error log prune failed: {e}"),
|
||||
}
|
||||
|
||||
// Load saved preferences for webview injection
|
||||
let t1 = Instant::now();
|
||||
let prefs_json = tauri::async_runtime::block_on(async {
|
||||
@@ -168,8 +218,11 @@ pub fn run() {
|
||||
{
|
||||
main_window
|
||||
.with_webview(|webview| {
|
||||
use webkit2gtk::glib::prelude::Cast;
|
||||
use webkit2gtk::{
|
||||
PermissionRequest, PermissionRequestExt, SettingsExt, WebViewExt,
|
||||
PermissionRequest, PermissionRequestExt, SettingsExt,
|
||||
UserMediaPermissionRequest, UserMediaPermissionRequestExt,
|
||||
WebViewExt,
|
||||
};
|
||||
|
||||
let wv: webkit2gtk::WebView = webview.inner().clone();
|
||||
@@ -180,11 +233,25 @@ pub fn run() {
|
||||
settings.set_enable_media_capabilities(true);
|
||||
}
|
||||
|
||||
// Auto-grant all permission requests (audio/video capture)
|
||||
// Auto-grant microphone capture only. Other WebKitGTK
|
||||
// permission requests are denied so future surfaces do
|
||||
// not inherit camera/geolocation/pointer-lock access.
|
||||
WebViewExt::connect_permission_request(
|
||||
&wv,
|
||||
|_wv, request: &PermissionRequest| {
|
||||
request.allow();
|
||||
if let Ok(media) =
|
||||
request.clone().downcast::<UserMediaPermissionRequest>()
|
||||
{
|
||||
if media.is_for_audio_device()
|
||||
&& !media.is_for_video_device()
|
||||
{
|
||||
request.allow();
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
true
|
||||
},
|
||||
);
|
||||
@@ -202,14 +269,20 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
// Close-to-tray: hide window instead of exiting
|
||||
let win = main_window.clone();
|
||||
main_window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = win.hide();
|
||||
}
|
||||
});
|
||||
// Close-to-tray: hide the window instead of exiting so the
|
||||
// tray icon stays as the canonical entry point. Desktop-only;
|
||||
// mobile has no tray and "hide on close" maps to the
|
||||
// platform's own background-app behaviour.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let win = main_window.clone();
|
||||
main_window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = win.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Store init script for secondary windows (float, viewer)
|
||||
@@ -218,6 +291,8 @@ pub fn run() {
|
||||
app.manage(commands::hotkey::HotkeyState::new());
|
||||
app.manage(commands::audio::NativeCaptureState::new());
|
||||
app.manage(commands::live::LiveTranscriptionState::default());
|
||||
app.manage(commands::tts::TtsState::new());
|
||||
app.manage(commands::meeting::MeetingState::new());
|
||||
|
||||
app.manage(AppState {
|
||||
whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),
|
||||
@@ -226,16 +301,12 @@ pub fn run() {
|
||||
llm_engine: Arc::new(LlmEngine::new()),
|
||||
});
|
||||
|
||||
{
|
||||
let whisper = app.state::<AppState>().whisper_engine.clone();
|
||||
crate::commands::models::prewarm_default_model(whisper);
|
||||
}
|
||||
|
||||
// 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());
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
if let Err(e) = tray::setup(app) {
|
||||
eprintln!("Failed to setup tray: {e}");
|
||||
}
|
||||
@@ -250,6 +321,7 @@ pub fn run() {
|
||||
commands::models::check_model,
|
||||
commands::models::list_models,
|
||||
commands::models::load_model,
|
||||
commands::models::prewarm_default_model_cmd,
|
||||
commands::models::check_engine,
|
||||
commands::models::get_runtime_capabilities,
|
||||
// Local LLM management
|
||||
@@ -284,10 +356,30 @@ pub fn run() {
|
||||
commands::tasks::complete_task_cmd,
|
||||
commands::tasks::delete_task_cmd,
|
||||
commands::tasks::uncomplete_task_cmd,
|
||||
commands::tasks::set_task_energy_cmd,
|
||||
commands::tasks::decompose_and_store,
|
||||
commands::tasks::extract_tasks_from_transcript_cmd,
|
||||
commands::tasks::list_subtasks_cmd,
|
||||
commands::tasks::complete_subtask_cmd,
|
||||
commands::tasks::list_recent_completions_cmd,
|
||||
// HITL feedback (Phase 2 roadmap)
|
||||
commands::feedback::record_feedback,
|
||||
commands::feedback::list_feedback_examples_cmd,
|
||||
// Read aloud (Phase 4 roadmap)
|
||||
commands::tts::tts_speak,
|
||||
commands::tts::tts_stop,
|
||||
commands::tts::tts_list_voices,
|
||||
// Rituals (Phase 5 roadmap)
|
||||
commands::rituals::get_last_morning_triage,
|
||||
commands::rituals::mark_morning_triage_shown,
|
||||
// Nudges (Phase 6 roadmap)
|
||||
commands::nudges::deliver_nudge,
|
||||
// Implementation intentions (Phase 7 roadmap)
|
||||
commands::intentions::list_implementation_rules,
|
||||
commands::intentions::create_implementation_rule,
|
||||
commands::intentions::set_implementation_rule_enabled,
|
||||
commands::intentions::mark_implementation_rule_fired,
|
||||
commands::intentions::delete_implementation_rule,
|
||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||
commands::profiles::list_profiles_cmd,
|
||||
commands::profiles::get_profile_cmd,
|
||||
@@ -323,6 +415,10 @@ pub fn run() {
|
||||
commands::windows::close_preview_window,
|
||||
// Clipboard
|
||||
commands::clipboard::copy_to_clipboard,
|
||||
// Filesystem (Phase 9 save-dialog path)
|
||||
commands::fs::write_text_file_cmd,
|
||||
// LLM content tags (Phase 9)
|
||||
commands::llm::extract_content_tags_cmd,
|
||||
// Paste (auto-insert at cursor)
|
||||
commands::paste::paste_text,
|
||||
commands::paste::paste_text_replacing,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?;
|
||||
let status = MenuItemBuilder::with_id("status", "Ready")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
// Phase 5: always-visible shortcut into the evening wind-down page.
|
||||
// The page itself only renders once the user has enabled the
|
||||
// ritual; clicking this when disabled is harmless (just takes them
|
||||
// there), and Settings is where the toggle lives.
|
||||
let wind_down = MenuItemBuilder::with_id("wind-down", "Evening wind-down").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
@@ -15,6 +20,8 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
.separator()
|
||||
.item(&status)
|
||||
.separator()
|
||||
.item(&wind_down)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
@@ -34,6 +41,15 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"wind-down" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
// The frontend layout listens for this event and routes
|
||||
// to the Phase 5 wind-down page.
|
||||
let _ = app.emit("kon:open-wind-down", ());
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
@@ -23,16 +23,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; connect-src ipc: http://ipc.localhost asset: https://asset.localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; media-src 'self' asset: https://asset.localhost"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://github.com/jakejars/kon/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": false,
|
||||
"pubkey": ""
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; connect-src ipc: http://ipc.localhost asset: https://asset.localhost http://127.0.0.1:* ws://127.0.0.1:*; media-src 'self' asset: https://asset.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -44,6 +35,9 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"android": {
|
||||
"minSdkVersion": 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src-tauri/tests/config_hardening.rs
Normal file
93
src-tauri/tests/config_hardening.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn manifest_dir() -> &'static Path {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
fn read_json(path: impl AsRef<Path>) -> Value {
|
||||
let raw = fs::read_to_string(path.as_ref()).expect("read json file");
|
||||
serde_json::from_str(&raw).expect("valid json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csp_keeps_loopback_narrow() {
|
||||
let conf = read_json(manifest_dir().join("tauri.conf.json"));
|
||||
let csp = conf
|
||||
.pointer("/app/security/csp")
|
||||
.and_then(Value::as_str)
|
||||
.expect("csp string");
|
||||
let connect_src = csp
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find(|directive| directive.starts_with("connect-src "))
|
||||
.expect("connect-src directive");
|
||||
|
||||
assert!(connect_src.contains("http://127.0.0.1:*"));
|
||||
assert!(connect_src.contains("ws://127.0.0.1:*"));
|
||||
assert!(!connect_src.contains("http://localhost:*"));
|
||||
assert!(!connect_src.contains("ws://localhost:*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updater_is_signed_or_absent() {
|
||||
let conf = read_json(manifest_dir().join("tauri.conf.json"));
|
||||
if let Some(updater) = conf.pointer("/plugins/updater") {
|
||||
let pubkey = updater
|
||||
.get("pubkey")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!pubkey.trim().is_empty(),
|
||||
"updater config must not ship with an empty pubkey"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_windows_do_not_get_high_risk_plugin_permissions() {
|
||||
let denied = [
|
||||
"dialog:",
|
||||
"autostart:",
|
||||
"global-shortcut:",
|
||||
"opener:",
|
||||
"updater:",
|
||||
];
|
||||
let capability_dir = manifest_dir().join("capabilities");
|
||||
|
||||
for entry in fs::read_dir(capability_dir).expect("capabilities dir") {
|
||||
let path = entry.expect("capability entry").path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let capability = read_json(&path);
|
||||
let windows = capability
|
||||
.get("windows")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let applies_to_secondary = windows.iter().any(|window| {
|
||||
matches!(
|
||||
window.as_str(),
|
||||
Some("tasks-float" | "transcript-viewer" | "transcription-preview")
|
||||
)
|
||||
});
|
||||
if !applies_to_secondary {
|
||||
continue;
|
||||
}
|
||||
let permissions = capability
|
||||
.get("permissions")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for permission in permissions.iter().filter_map(Value::as_str) {
|
||||
assert!(
|
||||
!denied.iter().any(|prefix| permission.starts_with(prefix)),
|
||||
"{} grants high-risk permission {permission} to a secondary window",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/app.css
39
src/app.css
@@ -50,13 +50,19 @@
|
||||
/* Text — warm hierarchy */
|
||||
--color-text: #f0ece4;
|
||||
--color-text-secondary: #9a9486;
|
||||
--color-text-tertiary: #716b60;
|
||||
/* Phase 10a a11y P1: lifted from #716b60 to #8c8678 to clear AA 4.5:1 on
|
||||
the dark surfaces this token is used on. Used as both decorative and
|
||||
body-grade label, so the token has to meet AA. */
|
||||
--color-text-tertiary: #8c8678;
|
||||
|
||||
/* Accent — warm amber/copper */
|
||||
--color-accent: #e8a87c;
|
||||
--color-accent-hover: #d4976a;
|
||||
--color-accent-subtle: #e8a87c10;
|
||||
--color-accent-glow: #e8a87c25;
|
||||
/* Accent — warm amber/copper.
|
||||
Phase 10a a11y P2: dark accent darkened from #e8a87c to #c98555 so the
|
||||
"Browse Files" filled button (white text on accent fill) clears AA in
|
||||
dark theme. Subtle / hover / glow recomputed off the new base. */
|
||||
--color-accent: #c98555;
|
||||
--color-accent-hover: #b8754a;
|
||||
--color-accent-subtle: #c9855510;
|
||||
--color-accent-glow: #c9855525;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #7ec89a;
|
||||
@@ -109,15 +115,22 @@
|
||||
|
||||
--color-text: #1a1816;
|
||||
--color-text-secondary: #5c574d;
|
||||
--color-text-tertiary: #8a8578;
|
||||
/* Phase 10a a11y P1: darkened from #8a8578 to #6b6557 to clear AA 4.5:1
|
||||
on cream/card surfaces in light theme. */
|
||||
--color-text-tertiary: #6b6557;
|
||||
|
||||
--color-accent: #b87a4a;
|
||||
--color-accent-hover: #a06b3e;
|
||||
--color-accent-subtle: #b87a4a10;
|
||||
--color-accent-glow: #b87a4a20;
|
||||
/* Phase 10a a11y P2: darkened from #b87a4a to #a06a3e so white text on
|
||||
accent fill (selected pills, primary buttons) clears AA. */
|
||||
--color-accent: #a06a3e;
|
||||
--color-accent-hover: #8a5a32;
|
||||
--color-accent-subtle: #a06a3e10;
|
||||
--color-accent-glow: #a06a3e20;
|
||||
|
||||
--color-success: #3d8a5a;
|
||||
--color-danger: #c44d4d;
|
||||
/* Phase 10a a11y G3: darkened success from #3d8a5a to #2f7549 so success
|
||||
text on light surfaces clears AA. Phase 10a a11y FR3 / D4: darkened
|
||||
danger from #c44d4d to #a83838 for the same reason. */
|
||||
--color-success: #2f7549;
|
||||
--color-danger: #a83838;
|
||||
--color-warning: #b89a3e;
|
||||
|
||||
--color-sidebar: #f5f2ed;
|
||||
|
||||
@@ -28,10 +28,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Layout-transition perf fix: previously animated `width` + `min-width` which
|
||||
triggers layout on every frame. Replaced with a CSS-grid track-size
|
||||
transition (`grid-template-columns`) on a wrapping container; the inner
|
||||
flex column lives in the single track. This keeps the parent flex row in
|
||||
charge of width allocation but moves the animated property onto a track
|
||||
spec, which the browser can collapse without re-running our layout work.
|
||||
-->
|
||||
<div
|
||||
class="grid h-full {collapsed ? 'grid-cols-[48px]' : 'grid-cols-[210px]'}"
|
||||
style="transition: grid-template-columns var(--duration-ui)"
|
||||
>
|
||||
<aside
|
||||
class="flex flex-col bg-sidebar border-r border-border-subtle h-full overflow-hidden
|
||||
{collapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}"
|
||||
style="transition: width var(--duration-ui), min-width var(--duration-ui)"
|
||||
class="flex flex-col bg-sidebar border-r border-border-subtle h-full overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Logo + toggle -->
|
||||
<div class="flex items-center {collapsed ? 'justify-center px-0 pt-4 pb-1' : 'px-5 pt-4 pb-1'} relative">
|
||||
@@ -165,3 +175,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
92
src/lib/components/CompletionSparkline.svelte
Normal file
92
src/lib/components/CompletionSparkline.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
// Phase 8. Tiny inline SVG sparkline. 7 bars, oldest to newest.
|
||||
// Zero-days render as a 1 px baseline stub (never a gap, never zero
|
||||
// height). The component self-hides when all bars are zero. Caller
|
||||
// still guards on the user's settings toggle.
|
||||
//
|
||||
// Phase 9 polish: friendlier aria-label (sentence-form summary, not a
|
||||
// numeric list), per-bar <title> tooltips with absolute date + count,
|
||||
// and a 30 ms-stagger entrance animation that respects
|
||||
// prefers-reduced-motion. Tabindex deliberately omitted: SVGs with
|
||||
// role="img" are reachable by screen-reader graphic navigation
|
||||
// without being part of the keyboard-tab order.
|
||||
import type { DailyCompletionCount } from "$lib/types/app";
|
||||
|
||||
interface Props {
|
||||
data: DailyCompletionCount[];
|
||||
/** Total width in px. Default 80. */
|
||||
width?: number;
|
||||
/** Total height in px. Default 16. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { data, width = 80, height = 16 }: Props = $props();
|
||||
|
||||
const BAR_GAP = 2;
|
||||
|
||||
let maxCount = $derived(Math.max(1, ...data.map((d) => d.count)));
|
||||
let barWidth = $derived(
|
||||
(width - BAR_GAP * Math.max(0, data.length - 1)) / Math.max(1, data.length),
|
||||
);
|
||||
|
||||
let total = $derived(data.reduce((sum, d) => sum + d.count, 0));
|
||||
let today = $derived(data.at(-1)?.count ?? 0);
|
||||
|
||||
let ariaLabel = $derived.by(() => {
|
||||
if (data.length === 0) return "";
|
||||
return `${today} completed today. ${total} total over the last ${data.length} days.`;
|
||||
});
|
||||
|
||||
let hasAnyCompletion = $derived(data.some((d) => d.count > 0));
|
||||
</script>
|
||||
|
||||
{#if hasAnyCompletion}
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
class="text-text-tertiary"
|
||||
>
|
||||
{#each data as d, i}
|
||||
{@const x = i * (barWidth + BAR_GAP)}
|
||||
{@const proportion = d.count / maxCount}
|
||||
{@const barHeight = Math.max(1, Math.round(proportion * height))}
|
||||
{@const y = height - barHeight}
|
||||
<rect
|
||||
{x}
|
||||
{y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
fill="currentColor"
|
||||
opacity={d.count === 0 ? 0.35 : 0.85}
|
||||
rx="1"
|
||||
class="bar"
|
||||
style={`--bar-delay: ${i * 30}ms`}
|
||||
>
|
||||
<title>{d.day}: {d.count} {d.count === 1 ? "task" : "tasks"}</title>
|
||||
</rect>
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
transform-origin: bottom;
|
||||
animation: bar-in 240ms ease var(--bar-delay, 0ms) both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@keyframes bar-in {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
106
src/lib/components/EnergyChip.svelte
Normal file
106
src/lib/components/EnergyChip.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
// Phase 3 — Energy tag chip. Cycles a task's energy level through
|
||||
// the spec's four states: unset → High → Medium → Brain-Dead → unset.
|
||||
//
|
||||
// Visual discipline: when energy is unset, the chip renders at
|
||||
// `group-hover` opacity only so untagged rows stay calm. Once set,
|
||||
// the chip is always visible because the colour IS the signal for
|
||||
// the match-my-energy sort.
|
||||
//
|
||||
// Colour choices borrow the existing design tokens:
|
||||
// High → accent (warm, on-brand, attention-ready)
|
||||
// Medium → warning (amber, unforced)
|
||||
// Brain-Dead → text-tertiary (low-energy grey, not danger red —
|
||||
// the brief is explicit that this state must not feel
|
||||
// pathologised)
|
||||
//
|
||||
// Callers pass the task's current energy and a setter. This component
|
||||
// owns no state — the task store is the source of truth.
|
||||
|
||||
import type { EnergyLevel } from "$lib/types/app";
|
||||
import { Zap } from "lucide-svelte";
|
||||
|
||||
let {
|
||||
energy = null as EnergyLevel | null,
|
||||
onSelect,
|
||||
size = "sm",
|
||||
reduceMotion = false,
|
||||
}: {
|
||||
energy: EnergyLevel | null;
|
||||
onSelect: (next: EnergyLevel | null) => void;
|
||||
size?: "sm" | "md";
|
||||
reduceMotion?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Cycle order lives here so the chip is the single authority on what
|
||||
// "next" means. Tap once to tag, tap again to move up, tap past
|
||||
// Brain-Dead to clear. Keyboard-equivalent via the <button> element.
|
||||
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
|
||||
|
||||
function next(): EnergyLevel | null {
|
||||
const idx = CYCLE.indexOf(energy);
|
||||
return CYCLE[(idx + 1) % CYCLE.length];
|
||||
}
|
||||
|
||||
function labelFor(level: EnergyLevel | null): string {
|
||||
switch (level) {
|
||||
case "high": return "High";
|
||||
case "medium": return "Medium";
|
||||
case "brain_dead": return "Brain-Dead";
|
||||
default: return "No energy set";
|
||||
}
|
||||
}
|
||||
|
||||
let tooltip = $derived(
|
||||
energy === null
|
||||
? "Tag energy (click to set)"
|
||||
: `Energy: ${labelFor(energy)} — click to change`
|
||||
);
|
||||
|
||||
// Icon dimensions. `md` is the one used on the Tasks-page main rows;
|
||||
// `sm` is for the compact WIP list rows and micro-step children.
|
||||
let iconSize = $derived(size === "md" ? 13 : 10);
|
||||
let chipSize = $derived(size === "md" ? "h-5" : "h-4");
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
|
||||
{energy === null
|
||||
? 'opacity-0 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
|
||||
: ''}
|
||||
{energy === 'high'
|
||||
? 'text-accent border-accent bg-accent/10'
|
||||
: ''}
|
||||
{energy === 'medium'
|
||||
? 'text-warning border-warning bg-warning/10'
|
||||
: ''}
|
||||
{energy === 'brain_dead'
|
||||
? 'text-text-tertiary border-border bg-hover'
|
||||
: ''}"
|
||||
onclick={() => onSelect(next())}
|
||||
aria-label={tooltip}
|
||||
title={tooltip}
|
||||
data-energy={energy ?? 'unset'}
|
||||
style={reduceMotion
|
||||
? ''
|
||||
: 'transition: opacity var(--duration-ui), color var(--duration-ui), border-color var(--duration-ui), background-color var(--duration-ui)'}
|
||||
>
|
||||
<Zap size={iconSize} aria-hidden="true" />
|
||||
{#if energy !== null && size === "md"}
|
||||
<span class="ml-1">{labelFor(energy)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.energy-chip {
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-family: var(--font-family-body);
|
||||
}
|
||||
|
||||
.energy-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
298
src/lib/components/FocusTimer.svelte
Normal file
298
src/lib/components/FocusTimer.svelte
Normal 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>
|
||||
266
src/lib/components/ImplementationRulesEditor.svelte
Normal file
266
src/lib/components/ImplementationRulesEditor.svelte
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
// @ts-nocheck
|
||||
import { onMount } from "svelte";
|
||||
import { Trash2 } from "lucide-svelte";
|
||||
import Toggle from "$lib/components/Toggle.svelte";
|
||||
import { tasks } from "$lib/stores/page.svelte.js";
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
import {
|
||||
createImplementationRule,
|
||||
deleteImplementationRule,
|
||||
implementationRules,
|
||||
initialLastFiredKeyForTimeRule,
|
||||
loadImplementationRules,
|
||||
ruleSummary,
|
||||
setImplementationRuleEnabled,
|
||||
} from "$lib/stores/implementationIntentions.svelte.ts";
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
let triggerKind = $state("time_of_day");
|
||||
let triggerTime = $state("09:00");
|
||||
let surfaceEnabled = $state(true);
|
||||
let surfaceTarget = $state("inbox");
|
||||
let surfaceTaskId = $state("");
|
||||
let startTimer = $state(false);
|
||||
let speakLine = $state("");
|
||||
|
||||
let activeTasks = $derived(tasks.filter((task) => !task.done && !task.parentTaskId));
|
||||
|
||||
onMount(() => {
|
||||
loading = true;
|
||||
loadImplementationRules(true)
|
||||
.catch((err) => { error = String(err); })
|
||||
.finally(() => { loading = false; });
|
||||
});
|
||||
|
||||
function triggerValue() {
|
||||
return triggerKind === "time_of_day" ? triggerTime : "";
|
||||
}
|
||||
|
||||
function builtActions() {
|
||||
const actions = [];
|
||||
if (surfaceEnabled) {
|
||||
actions.push({
|
||||
kind: "surface",
|
||||
target: surfaceTarget,
|
||||
taskId: surfaceTarget === "task" ? surfaceTaskId : null,
|
||||
label: surfaceTarget === "task"
|
||||
? activeTasks.find((task) => task.id === surfaceTaskId)?.text ?? "Task"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (startTimer) {
|
||||
const selectedTask = activeTasks.find((task) => task.id === surfaceTaskId);
|
||||
actions.push({
|
||||
kind: "start_timer",
|
||||
seconds: 300,
|
||||
taskId: surfaceTarget === "task" ? surfaceTaskId : null,
|
||||
label: selectedTask?.text ?? "5-minute timer",
|
||||
});
|
||||
}
|
||||
if (speakLine.trim()) {
|
||||
actions.push({ kind: "speak_line", text: speakLine.trim() });
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function canSave() {
|
||||
if (saving) return false;
|
||||
if (triggerKind === "time_of_day" && !/^\d{2}:\d{2}$/.test(triggerTime)) return false;
|
||||
if (surfaceEnabled && surfaceTarget === "task" && !surfaceTaskId) return false;
|
||||
return builtActions().length > 0;
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
if (!canSave()) return;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
await createImplementationRule({
|
||||
enabled: true,
|
||||
triggerKind,
|
||||
triggerValue: triggerValue(),
|
||||
actions: builtActions(),
|
||||
lastFiredKey: triggerKind === "time_of_day"
|
||||
? initialLastFiredKeyForTimeRule(triggerTime)
|
||||
: null,
|
||||
});
|
||||
toasts.success("Rule saved");
|
||||
triggerKind = "time_of_day";
|
||||
triggerTime = "09:00";
|
||||
surfaceEnabled = true;
|
||||
surfaceTarget = "inbox";
|
||||
surfaceTaskId = "";
|
||||
startTimer = false;
|
||||
speakLine = "";
|
||||
} catch (err) {
|
||||
error = String(err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRule(rule, enabled) {
|
||||
try {
|
||||
await setImplementationRuleEnabled(rule.id, enabled);
|
||||
} catch (err) {
|
||||
toasts.warn("Could not update rule", String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRule(rule) {
|
||||
try {
|
||||
await deleteImplementationRule(rule.id);
|
||||
} catch (err) {
|
||||
toasts.warn("Could not delete rule", String(err));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
Build simple if-then rules. They respect Mute for now in Nudges, and everything stays local.
|
||||
</p>
|
||||
|
||||
<div class="rounded-lg border border-border-subtle bg-bg-input p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[120px_1fr] gap-3 items-start">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider pt-2">If</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={triggerKind}
|
||||
>
|
||||
<option value="time_of_day">time of day</option>
|
||||
<option value="task_completed">a task completes</option>
|
||||
<option value="morning_triage_finished">morning triage finishes</option>
|
||||
</select>
|
||||
{#if triggerKind === "time_of_day"}
|
||||
<input
|
||||
type="time"
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={triggerTime}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider pt-2">Then</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
bind:checked={surfaceEnabled}
|
||||
label="Surface a list or task"
|
||||
description="Open Tasks and put the chosen item back in view."
|
||||
/>
|
||||
{#if surfaceEnabled}
|
||||
<div class="flex flex-wrap gap-2 pl-1">
|
||||
<select
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={surfaceTarget}
|
||||
>
|
||||
<option value="inbox">Inbox</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="tasks">All tasks</option>
|
||||
<option value="task">Specific task</option>
|
||||
</select>
|
||||
{#if surfaceTarget === "task"}
|
||||
<select
|
||||
class="min-w-[220px] bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={surfaceTaskId}
|
||||
>
|
||||
<option value="">Choose task…</option>
|
||||
{#each activeTasks as task (task.id)}
|
||||
<option value={task.id}>{task.text}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
bind:checked={startTimer}
|
||||
label="Start a 5-minute timer"
|
||||
description="Use the same focus timer as micro-steps."
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="rule-speak-line" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
|
||||
Speak aloud
|
||||
</label>
|
||||
<input
|
||||
id="rule-speak-line"
|
||||
type="text"
|
||||
maxlength="240"
|
||||
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text placeholder:text-text-tertiary focus:border-accent"
|
||||
placeholder="Optional line, e.g. time to plan the day"
|
||||
bind:value={speakLine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-[11px] text-danger mt-3">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg text-[12px] font-medium
|
||||
{canSave()
|
||||
? 'bg-accent text-bg hover:bg-accent-hover'
|
||||
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'}"
|
||||
disabled={!canSave()}
|
||||
onclick={saveRule}
|
||||
>
|
||||
{saving ? "Saving…" : "Save rule"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Saved rules</p>
|
||||
{#if loading}
|
||||
<p class="text-[11px] text-text-tertiary">Loading…</p>
|
||||
{:else if implementationRules.length === 0}
|
||||
<p class="text-[11px] text-text-tertiary italic">No rules yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each implementationRules as rule (rule.id)}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-input px-3 py-2">
|
||||
<button
|
||||
class="relative w-[34px] min-w-[34px] h-[20px] rounded-full flex-shrink-0
|
||||
{rule.enabled ? 'bg-accent' : 'bg-bg-elevated'}"
|
||||
role="switch"
|
||||
aria-checked={rule.enabled}
|
||||
aria-label={rule.enabled ? 'Disable rule' : 'Enable rule'}
|
||||
onclick={() => toggleRule(rule, !rule.enabled)}
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-3.5 h-3.5 rounded-full bg-white shadow-sm
|
||||
{rule.enabled ? 'translate-x-[14px]' : 'translate-x-0'}"
|
||||
style="transition: transform var(--duration-ui)"
|
||||
></span>
|
||||
</button>
|
||||
<p class="flex-1 min-w-0 text-[12px] text-text-secondary leading-snug">
|
||||
{ruleSummary(rule)}
|
||||
</p>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-danger"
|
||||
aria-label="Delete rule"
|
||||
title="Delete rule"
|
||||
onclick={() => removeRule(rule)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,10 @@
|
||||
<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';
|
||||
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
|
||||
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||
|
||||
let { parentTaskId, reduceMotion = false } = $props();
|
||||
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
||||
|
||||
interface Subtask {
|
||||
id: string;
|
||||
@@ -15,6 +17,22 @@
|
||||
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>>({});
|
||||
|
||||
// Monotonic token per-step. Each time the user kicks off a save we
|
||||
// bump the token; the pending save remembers its token and only
|
||||
// rolls back if the failure belongs to the still-current edit.
|
||||
// Without this, a slow first save that eventually fails will stomp
|
||||
// a faster second save that already committed.
|
||||
let saveToken = $state<Record<string, number>>({});
|
||||
|
||||
async function loadSubtasks() {
|
||||
loading = true;
|
||||
error = '';
|
||||
@@ -31,7 +49,19 @@
|
||||
decomposing = true;
|
||||
error = '';
|
||||
try {
|
||||
subtasks = await invoke<Subtask[]>('decompose_and_store', { parentTaskId });
|
||||
subtasks = await invoke<Subtask[]>('decompose_and_store', {
|
||||
parentTaskId,
|
||||
profileId: profilesStore.activeProfileId,
|
||||
});
|
||||
// Phase 6 nudge-bus signal. The bus schedules a 15-min idle
|
||||
// check on this parent task id — if no step or the task itself
|
||||
// is completed in that window, a gentle "still with that one?"
|
||||
// nudge fires.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('kon:microstep-generated', {
|
||||
detail: { parentTaskId },
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
@@ -44,6 +74,14 @@
|
||||
await invoke('complete_subtask_cmd', { subtaskId });
|
||||
const idx = subtasks.findIndex(s => s.id === subtaskId);
|
||||
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], done: true };
|
||||
// Phase 6 nudge-bus signal. Any completed step clears the
|
||||
// micro-step-idle timer for this parent task — the breakdown
|
||||
// is demonstrably getting worked, no nudge needed.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('kon:step-completed', {
|
||||
detail: { id: subtaskId, parentTaskId },
|
||||
}));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -53,6 +91,101 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// --- 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(),
|
||||
profileId: profilesStore.activeProfileId,
|
||||
},
|
||||
});
|
||||
} 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.
|
||||
// The monotonic `myToken` guards against a stale rollback stomping
|
||||
// a fresher successful save that landed while we were waiting.
|
||||
const myToken = (saveToken[step.id] ?? 0) + 1;
|
||||
saveToken = { ...saveToken, [step.id]: myToken };
|
||||
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(),
|
||||
profileId: profilesStore.activeProfileId,
|
||||
},
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
// Only roll back if our token is still the most recent — a later
|
||||
// edit that already succeeded must not be overwritten.
|
||||
if (saveToken[step.id] === myToken) {
|
||||
const rollbackIdx = subtasks.findIndex(s => s.id === step.id);
|
||||
if (rollbackIdx >= 0) {
|
||||
subtasks[rollbackIdx] = { ...subtasks[rollbackIdx], 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 +230,70 @@
|
||||
<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>
|
||||
<span
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
|
||||
>
|
||||
<SpeakerButton text={step.text} label="Read this step aloud" size={10} />
|
||||
</span>
|
||||
<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)}
|
||||
|
||||
356
src/lib/components/MorningTriageModal.svelte
Normal file
356
src/lib/components/MorningTriageModal.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
// @ts-nocheck
|
||||
// Phase 5 morning triage. Surfaces a calm "pick up to three for today"
|
||||
// modal on the first launch-of-day once the user-set trigger time has
|
||||
// passed. Evidence-based "rule of 3" per Life Skills Advocate / Aspire
|
||||
// Therapy ADHD routine literature: externalise the daily choice and
|
||||
// cap it at 3 to protect working memory (Sweller cognitive-load theory,
|
||||
// Barkley's point-of-performance principle).
|
||||
//
|
||||
// Copy audit: no "overdue", no "failed", no subtractive framing (RSD).
|
||||
//
|
||||
// Triggering: runs a lightweight check on mount and when the page
|
||||
// regains focus. Only one modal per calendar day regardless of how
|
||||
// many times the app is restarted.
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
||||
import { settings } from '$lib/stores/page.svelte.js';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.js';
|
||||
|
||||
interface TriageTask {
|
||||
id: string;
|
||||
text: string;
|
||||
bucket: string;
|
||||
done: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let tasks = $state<TriageTask[]>([]);
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let tooManyFlash = $state(false);
|
||||
let applying = $state(false);
|
||||
let focusHandler: (() => void) | null = null;
|
||||
// Phase 10a a11y G4: focus-trap state. The modal must keep keyboard
|
||||
// focus inside its bounds while open and restore focus to the
|
||||
// invoking element on close.
|
||||
let dialogEl = $state<HTMLDivElement | null>(null);
|
||||
let invokerEl: HTMLElement | null = null;
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Parse a HH:MM string into minutes-since-midnight. Falls back to
|
||||
// 08:00 on malformed input rather than throwing — rituals must never
|
||||
// break the app shell.
|
||||
function parseTriggerMinutes(hhmm: string | undefined): number {
|
||||
if (typeof hhmm !== 'string') return 8 * 60;
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
||||
if (!match) return 8 * 60;
|
||||
const h = Math.max(0, Math.min(23, parseInt(match[1], 10)));
|
||||
const m = Math.max(0, Math.min(59, parseInt(match[2], 10)));
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function currentMinutes(): number {
|
||||
const d = new Date();
|
||||
return d.getHours() * 60 + d.getMinutes();
|
||||
}
|
||||
|
||||
function dispatchTriageFinished(mode: 'empty' | 'skipped' | 'picked') {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent('kon:morning-triage-finished', {
|
||||
detail: { date: todayKey(), mode },
|
||||
}));
|
||||
}
|
||||
|
||||
function isBeforeToday(createdAt: string): boolean {
|
||||
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
|
||||
// local-time date portion so a task made last night locally counts
|
||||
// as "yesterday" regardless of the UTC offset.
|
||||
const d = new Date(createdAt);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return local < todayKey();
|
||||
}
|
||||
|
||||
async function maybeShow() {
|
||||
if (open || applying) return;
|
||||
if (!settings.ritualsMorning) return;
|
||||
if (!hasTauriRuntime()) return;
|
||||
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
|
||||
|
||||
let lastShown: string | null = null;
|
||||
try {
|
||||
lastShown = await invoke<string | null>('get_last_morning_triage');
|
||||
} catch {
|
||||
// Non-fatal: if we can't read the sentinel, treat as never-shown.
|
||||
}
|
||||
if (lastShown === todayKey()) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const all = await invoke<TriageTask[]>('list_tasks_cmd');
|
||||
tasks = all.filter(
|
||||
(t) => !t.done && t.bucket !== 'today' && isBeforeToday(t.createdAt),
|
||||
);
|
||||
} catch {
|
||||
tasks = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
// Nothing to triage — record the shown sentinel anyway so we
|
||||
// don't re-check the DB every focus event today.
|
||||
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
|
||||
dispatchTriageFinished('empty');
|
||||
return;
|
||||
}
|
||||
|
||||
selected = new Set();
|
||||
open = true;
|
||||
}
|
||||
|
||||
function toggle(taskId: string) {
|
||||
if (selected.has(taskId)) {
|
||||
const next = new Set(selected);
|
||||
next.delete(taskId);
|
||||
selected = next;
|
||||
return;
|
||||
}
|
||||
if (selected.size >= 3) {
|
||||
tooManyFlash = true;
|
||||
setTimeout(() => { tooManyFlash = false; }, 1800);
|
||||
return;
|
||||
}
|
||||
const next = new Set(selected);
|
||||
next.add(taskId);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
async function skipForToday() {
|
||||
applying = true;
|
||||
try {
|
||||
await invoke('mark_morning_triage_shown', { date: todayKey() });
|
||||
} catch (err) {
|
||||
toasts.warn('Could not save triage state', String(err));
|
||||
} finally {
|
||||
applying = false;
|
||||
open = false;
|
||||
dispatchTriageFinished('skipped');
|
||||
}
|
||||
}
|
||||
|
||||
async function startTheDay() {
|
||||
if (selected.size === 0) return;
|
||||
applying = true;
|
||||
try {
|
||||
for (const id of selected) {
|
||||
try {
|
||||
await invoke('update_task_cmd', {
|
||||
id,
|
||||
patch: { bucket: 'today' },
|
||||
});
|
||||
} catch (err) {
|
||||
// Continue the loop — surface a single toast at the end rather
|
||||
// than one per failure, so the user isn't drowned in errors.
|
||||
console.warn('Triage: failed to move task', id, err);
|
||||
}
|
||||
}
|
||||
await invoke('mark_morning_triage_shown', { date: todayKey() });
|
||||
} catch (err) {
|
||||
toasts.warn('Could not save triage state', String(err));
|
||||
} finally {
|
||||
applying = false;
|
||||
open = false;
|
||||
dispatchTriageFinished('picked');
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 10a a11y G4: collect every focusable element inside the dialog
|
||||
// for the focus trap. We re-query on every Tab so dynamic content
|
||||
// (loading → tasks → buttons) stays in sync.
|
||||
function focusableInDialog(): HTMLElement[] {
|
||||
if (!dialogEl) return [];
|
||||
const sel = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
return Array.from(dialogEl.querySelectorAll<HTMLElement>(sel)).filter(
|
||||
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
skipForToday();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusables = focusableInDialog();
|
||||
if (focusables.length === 0) {
|
||||
// Nothing focusable inside — keep focus on the dialog container.
|
||||
e.preventDefault();
|
||||
dialogEl?.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !dialogEl?.contains(active)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (active === last || !dialogEl?.contains(active)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 10a a11y G4: when the dialog opens, remember the invoker so
|
||||
// we can return focus on close, and move focus into the dialog.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
invokerEl = (document.activeElement as HTMLElement) ?? null;
|
||||
// Wait one tick for the dialog to mount before focusing.
|
||||
queueMicrotask(() => {
|
||||
const focusables = focusableInDialog();
|
||||
(focusables[0] ?? dialogEl)?.focus();
|
||||
});
|
||||
} else if (invokerEl) {
|
||||
// Restore focus to the element that opened the modal.
|
||||
try { invokerEl.focus(); } catch {}
|
||||
invokerEl = null;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
maybeShow();
|
||||
focusHandler = () => maybeShow();
|
||||
window.addEventListener('focus', focusHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (focusHandler) window.removeEventListener('focus', focusHandler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!--
|
||||
Pure-black-white taste fix: replaced `bg-black/50` overlay with the brand
|
||||
deep-neutral `#1a1816` at 50% alpha. TODO: promote this colour to a
|
||||
`--color-overlay` token in app.css so the value isn't inlined per-modal.
|
||||
-->
|
||||
<div
|
||||
bind:this={dialogEl}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm animate-fade-in"
|
||||
style="background: rgba(26, 24, 22, 0.5)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="triage-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col">
|
||||
<div class="px-6 pt-6 pb-3">
|
||||
<h2 id="triage-title" class="font-display text-[22px] italic text-text">Pick up to three for today</h2>
|
||||
<p class="text-[12px] text-text-secondary mt-1">
|
||||
Yesterday's open items. The rest can wait.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 pb-3 min-h-0">
|
||||
{#if loading}
|
||||
<p class="text-[12px] text-text-tertiary py-6 text-center">Loading your list…</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each tasks as task (task.id)}
|
||||
{@const picked = selected.has(task.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left flex items-start gap-3 px-3 py-2 rounded-lg border transition-colors
|
||||
{picked
|
||||
? 'bg-accent/10 border-accent text-text'
|
||||
: 'bg-bg-input border-border-subtle text-text-secondary hover:border-border'}"
|
||||
onclick={() => toggle(task.id)}
|
||||
aria-pressed={picked}
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
|
||||
{picked ? 'bg-accent border-accent text-white' : 'border-border'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if picked}
|
||||
<svg viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-[13px] leading-snug">{task.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-5 pt-2">
|
||||
<p
|
||||
class="text-[11px] mb-3 min-h-[16px] transition-colors
|
||||
{tooManyFlash ? 'text-warning' : 'text-text-tertiary'}"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if tooManyFlash}
|
||||
Just three for today. Unpick one to swap.
|
||||
{:else if selected.size > 0}
|
||||
{selected.size} picked · room for {3 - selected.size} more
|
||||
{:else}
|
||||
Pick 1, 2, or 3.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-[12px] text-text-tertiary hover:text-text"
|
||||
onclick={skipForToday}
|
||||
disabled={applying}
|
||||
>
|
||||
Skip for today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
|
||||
{selected.size >= 1 && !applying
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
|
||||
onclick={startTheDay}
|
||||
disabled={selected.size === 0 || applying}
|
||||
>
|
||||
{applying ? 'Saving…' : 'Start the day'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
69
src/lib/components/SettingsGroup.svelte
Normal file
69
src/lib/components/SettingsGroup.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
// Phase 9. Progressive-disclosure wrapper around the native <details>
|
||||
// element. The chevron animates on open via a CSS transform; height
|
||||
// animation uses interpolate-size where the engine supports it
|
||||
// (modern Chromium / WebKit), and falls back to instant open/close
|
||||
// elsewhere. prefers-reduced-motion disables both.
|
||||
import { ChevronRight } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
open?: boolean;
|
||||
onopen?: () => void;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { title, description = "", open = false, onopen, children }: Props = $props();
|
||||
|
||||
// Fires once on the first transition from closed → open. Used by
|
||||
// sections that lazy-load expensive data (e.g. TTS voice enumeration).
|
||||
// Plain let (not $state) — the flag is mutated only by the toggle
|
||||
// handler and never has to be reactive in the template.
|
||||
let hasOpened = false;
|
||||
function handleToggle(event: Event) {
|
||||
const el = event.currentTarget as HTMLDetailsElement | null;
|
||||
if (el?.open && !hasOpened) {
|
||||
hasOpened = true;
|
||||
onopen?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<details {open} ontoggle={handleToggle} class="settings-group border-t border-border-subtle">
|
||||
<summary
|
||||
class="flex items-start gap-2 cursor-pointer list-none py-3 px-1 rounded hover:bg-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
class="chevron size-4 mt-0.5 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-text font-medium">{title}</p>
|
||||
{#if description}
|
||||
<p class="text-[11px] text-text-tertiary mt-0.5">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</summary>
|
||||
<div class="pl-6 pr-2 pb-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.settings-group :global(summary)::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
:global(.chevron) {
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
details[open] > summary :global(.chevron) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.chevron) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
112
src/lib/components/SpeakerButton.svelte
Normal file
112
src/lib/components/SpeakerButton.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Volume2, Square } from 'lucide-svelte';
|
||||
import { settings } from '$lib/stores/page.svelte.js';
|
||||
import { activeSpeaker, setActiveSpeaker } from '$lib/stores/speaker.svelte.ts';
|
||||
|
||||
let {
|
||||
text,
|
||||
label = 'Read aloud',
|
||||
size = 12,
|
||||
}: { text: string; label?: string; size?: number } = $props();
|
||||
|
||||
// A stable id per mounted button. `crypto.randomUUID` is available
|
||||
// in any runtime recent enough to host Tauri's webview.
|
||||
const instanceId =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `sp_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
let revertTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const speaking = $derived(activeSpeaker.id === instanceId);
|
||||
|
||||
async function toggle() {
|
||||
if (speaking) {
|
||||
await stop();
|
||||
} else {
|
||||
await start();
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
// If a different button is active, cancel its speech first so we
|
||||
// don't get two synths talking over each other.
|
||||
if (activeSpeaker.id !== null && activeSpeaker.id !== instanceId) {
|
||||
try {
|
||||
await invoke('tts_stop');
|
||||
} catch {
|
||||
// Best-effort: nothing to gain from surfacing a stop error.
|
||||
}
|
||||
}
|
||||
setActiveSpeaker(instanceId);
|
||||
try {
|
||||
await invoke('tts_speak', {
|
||||
text,
|
||||
rate: settings.ttsRate,
|
||||
voice: settings.ttsVoice ?? null,
|
||||
});
|
||||
} catch {
|
||||
setActiveSpeaker(null);
|
||||
return;
|
||||
}
|
||||
scheduleRevert();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
clearRevert();
|
||||
setActiveSpeaker(null);
|
||||
try {
|
||||
await invoke('tts_stop');
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// No platform exposes a "speech finished" signal cheaply, so we
|
||||
// estimate duration from word count and revert the icon when it
|
||||
// elapses. 150 wpm is a comfortable pace for British-English; the
|
||||
// user's rate slider shortens or lengthens the estimate.
|
||||
function scheduleRevert() {
|
||||
clearRevert();
|
||||
const words = text.trim().split(/\s+/).filter(Boolean).length;
|
||||
const baseSeconds = Math.max(2, Math.min(600, (words / 150) * 60));
|
||||
const rate = settings.ttsRate > 0 ? settings.ttsRate : 1.0;
|
||||
const ms = Math.round((baseSeconds * 1000) / rate);
|
||||
revertTimer = setTimeout(() => {
|
||||
if (activeSpeaker.id === instanceId) {
|
||||
setActiveSpeaker(null);
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function clearRevert() {
|
||||
if (revertTimer !== null) {
|
||||
clearTimeout(revertTimer);
|
||||
revertTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearRevert();
|
||||
if (activeSpeaker.id === instanceId) {
|
||||
setActiveSpeaker(null);
|
||||
invoke('tts_stop').catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 text-text-tertiary hover:text-accent {speaking ? '!text-accent' : ''}"
|
||||
onclick={toggle}
|
||||
aria-label={speaking ? 'Stop reading aloud' : label}
|
||||
title={speaking ? 'Stop reading aloud' : label}
|
||||
>
|
||||
{#if speaking}
|
||||
<Square {size} aria-hidden="true" />
|
||||
{:else}
|
||||
<Volume2 {size} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -63,7 +63,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder:text-text-tertiary focus:border-accent"
|
||||
placeholder="Add task..."
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
||||
ease-[cubic-bezier(0.34,1.56,0.64,1)]
|
||||
ease-[cubic-bezier(0.16,1,0.3,1)]
|
||||
{checked ? 'translate-x-[16px]' : 'translate-x-0'}"
|
||||
style="transition: transform var(--duration-ui) cubic-bezier(0.34, 1.56, 0.64, 1)"
|
||||
style="transition: transform var(--duration-ui) cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
|
||||
import { tasks, addTask, completeTask, uncompleteTask, deleteTask, setTaskEnergy } from '$lib/stores/page.svelte.js';
|
||||
import MicroSteps from '$lib/components/MicroSteps.svelte';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||
import EnergyChip from '$lib/components/EnergyChip.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();
|
||||
|
||||
@@ -46,7 +53,7 @@
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Add a task…"
|
||||
class="flex-1 px-3 py-2 rounded-lg bg-bg-input border border-border-subtle text-text text-[13px]
|
||||
placeholder:text-text-tertiary focus:border-accent focus:outline-none"
|
||||
placeholder:text-text-tertiary focus:border-accent"
|
||||
data-no-transition
|
||||
/>
|
||||
</div>
|
||||
@@ -68,6 +75,22 @@
|
||||
aria-label="Complete task"
|
||||
></button>
|
||||
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
|
||||
<!-- Energy chip (Phase 3) — compact, reveals on hover when unset -->
|
||||
<EnergyChip
|
||||
energy={task.energy}
|
||||
onSelect={(next) => setTaskEnergy(task.id, next)}
|
||||
size="sm"
|
||||
/>
|
||||
<!-- 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 +114,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}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import { bionicReading } from '$lib/actions/bionicReading.js';
|
||||
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
||||
@@ -487,7 +488,10 @@
|
||||
if (settings.aiTier === "tasks" && llmLoaded) {
|
||||
markGenerating("Extracting tasks");
|
||||
try {
|
||||
const items = await invoke("extract_tasks_from_transcript_cmd", { transcript: text });
|
||||
const items = await invoke("extract_tasks_from_transcript_cmd", {
|
||||
transcript: text,
|
||||
profileId: profilesStore.activeProfileId,
|
||||
});
|
||||
markGenerationDone(true);
|
||||
return items.map((taskText) => ({ text: taskText }));
|
||||
} catch (err) {
|
||||
@@ -1066,6 +1070,9 @@
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
||||
</span>
|
||||
{#if transcript.trim()}
|
||||
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
<Card classes="h-full flex flex-col">
|
||||
<textarea
|
||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary"
|
||||
resize-none placeholder:text-text-tertiary"
|
||||
placeholder="Transcribed text will appear here..."
|
||||
bind:value={fileTranscript}
|
||||
data-no-transition
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// @ts-nocheck
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { page, settings } from "$lib/stores/page.svelte.js";
|
||||
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
import UnicodeSpinner from "$lib/components/UnicodeSpinner.svelte";
|
||||
import { Download, CheckCircle } from 'lucide-svelte';
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
import { Download, CheckCircle, Sunrise, Moon, Play } from 'lucide-svelte';
|
||||
|
||||
let systemInfo = $state(null);
|
||||
let models = $state([]);
|
||||
@@ -75,8 +76,15 @@
|
||||
}
|
||||
|
||||
ready = true;
|
||||
// Brief "Ready" beat, then onto the rituals prompt (or straight
|
||||
// to dictation if the user has already seen it).
|
||||
setTimeout(() => {
|
||||
page.current = "dictation";
|
||||
if (settings.ritualsPromptSeen) {
|
||||
page.current = "dictation";
|
||||
} else {
|
||||
ready = false;
|
||||
ritualsStep = "morning";
|
||||
}
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
error = `Download failed: ${e}`;
|
||||
@@ -87,7 +95,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: forced-choice rituals + autostart prompts. Research on
|
||||
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
|
||||
// drive uptake, but the ADHD target audience is sensitive to
|
||||
// parental framing — so we present explicit opt-in with calm copy
|
||||
// rather than defaulting anything on.
|
||||
type RitualsStep = "idle" | "morning" | "evening" | "autostart" | "done";
|
||||
let ritualsStep = $state<RitualsStep>("idle");
|
||||
let autostartApplying = $state(false);
|
||||
|
||||
async function answerMorning(yes: boolean) {
|
||||
settings.ritualsMorning = yes;
|
||||
saveSettings();
|
||||
ritualsStep = "evening";
|
||||
}
|
||||
|
||||
async function answerEvening(yes: boolean) {
|
||||
settings.ritualsEvening = yes;
|
||||
saveSettings();
|
||||
ritualsStep = "autostart";
|
||||
}
|
||||
|
||||
async function answerAutostart(yes: boolean) {
|
||||
autostartApplying = true;
|
||||
try {
|
||||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||
if (yes) {
|
||||
await plugin.enable();
|
||||
settings.launchAtLogin = true;
|
||||
} else {
|
||||
// Don't call disable() on a fresh install — there's nothing to
|
||||
// disable, and some platforms treat "disable when unset" as an
|
||||
// error. Just record the choice.
|
||||
settings.launchAtLogin = false;
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.warn("Could not update autostart", String(err));
|
||||
} finally {
|
||||
autostartApplying = false;
|
||||
settings.ritualsPromptSeen = true;
|
||||
saveSettings();
|
||||
ritualsStep = "done";
|
||||
setTimeout(() => { page.current = "dictation"; }, 900);
|
||||
}
|
||||
}
|
||||
|
||||
function skipRituals() {
|
||||
settings.ritualsPromptSeen = true;
|
||||
saveSettings();
|
||||
page.current = "dictation";
|
||||
}
|
||||
|
||||
function skipSetup() {
|
||||
// Skipping model download still marks the rituals prompt as seen —
|
||||
// the user chose to bypass the walk-through; they can find rituals
|
||||
// in Settings when they're ready.
|
||||
settings.ritualsPromptSeen = true;
|
||||
saveSettings();
|
||||
page.current = "dictation";
|
||||
}
|
||||
|
||||
@@ -107,7 +171,84 @@
|
||||
<div class="text-center">
|
||||
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
|
||||
<h2 class="text-xl font-medium text-text mt-4">Ready to go</h2>
|
||||
<p class="text-sm text-text-secondary mt-2">Press the button. Start talking. That's it.</p>
|
||||
<p class="text-sm text-text-secondary mt-2">A few quick choices, then you're in.</p>
|
||||
</div>
|
||||
|
||||
{:else if ritualsStep === "morning"}
|
||||
<div class="w-full max-w-md mx-auto text-center">
|
||||
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
|
||||
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||
On the first launch of the day, a gentle modal shows yesterday's open items and asks you to pick up to three for today. The rest can wait.
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-3">Off by default. You can change your mind any time in Settings.</p>
|
||||
<div class="flex items-center justify-center gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||
onclick={() => answerMorning(false)}
|
||||
>No thanks</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
||||
onclick={() => answerMorning(true)}
|
||||
>Yes, turn it on</button>
|
||||
</div>
|
||||
<button
|
||||
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||
onclick={skipRituals}
|
||||
>Skip all these questions</button>
|
||||
</div>
|
||||
|
||||
{:else if ritualsStep === "evening"}
|
||||
<div class="w-full max-w-md mx-auto text-center">
|
||||
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
|
||||
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||
A reflective page you can open when you want to close the day. Shows what you finished, names the open loops, then gets out of the way. Never scheduled, never nagging.
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Always opt-in.</p>
|
||||
<div class="flex items-center justify-center gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||
onclick={() => answerEvening(false)}
|
||||
>No thanks</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
||||
onclick={() => answerEvening(true)}
|
||||
>Yes, turn it on</button>
|
||||
</div>
|
||||
<button
|
||||
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||
onclick={skipRituals}
|
||||
>Skip the rest</button>
|
||||
</div>
|
||||
|
||||
{:else if ritualsStep === "autostart"}
|
||||
<div class="w-full max-w-md mx-auto text-center">
|
||||
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||
<h2 class="text-xl font-medium text-text">Launch Corbie at login?</h2>
|
||||
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||
So Corbie is already there when you need it — especially useful if you said yes to morning triage. Uses your OS's standard autostart. No background tricks, no telemetry.
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-3">You can change this any time in Settings.</p>
|
||||
<div class="flex items-center justify-center gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||
onclick={() => answerAutostart(false)}
|
||||
disabled={autostartApplying}
|
||||
>No thanks</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
|
||||
onclick={() => answerAutostart(true)}
|
||||
disabled={autostartApplying}
|
||||
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if ritualsStep === "done"}
|
||||
<div class="text-center">
|
||||
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
|
||||
<h2 class="text-xl font-medium text-text mt-4">All set</h2>
|
||||
<p class="text-sm text-text-secondary mt-2">Press the button. Start talking.</p>
|
||||
</div>
|
||||
|
||||
{:else if downloading}
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
// @ts-nocheck
|
||||
import { onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
|
||||
import {
|
||||
history,
|
||||
saveTranscriptMeta,
|
||||
deleteFromHistory,
|
||||
deleteFromHistoryById,
|
||||
renameHistoryEntry,
|
||||
} from "$lib/stores/page.svelte.js";
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
deriveAutoTags, buildFrontmatter, buildMarkdown, normaliseTag,
|
||||
} from "$lib/utils/frontmatter.js";
|
||||
import {
|
||||
saveTranscriptAsMarkdown,
|
||||
exportTranscriptsToDir,
|
||||
} from "$lib/utils/saveMarkdown";
|
||||
import { getPreferences } from "$lib/stores/preferences.svelte.js";
|
||||
import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js";
|
||||
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
|
||||
@@ -16,7 +26,7 @@
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star } from 'lucide-svelte';
|
||||
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
|
||||
|
||||
const prefs = getPreferences();
|
||||
const COLLAPSED_ROW_MIN_HEIGHT = 54;
|
||||
@@ -40,6 +50,9 @@
|
||||
let showStarredOnly = $state(false);
|
||||
let activeTagFilter = $state(null); // null = all; string = tag value
|
||||
let expandedId = $state(null);
|
||||
// Phase 9 bulk selection. Selection state is frontend-only and resets
|
||||
// on page refresh by design.
|
||||
let selected = $state(new Set());
|
||||
let playingId = $state(null);
|
||||
let audioEl = $state(null);
|
||||
let currentTime = $state(0);
|
||||
@@ -215,12 +228,27 @@
|
||||
return items;
|
||||
});
|
||||
|
||||
function clearAll() {
|
||||
async function clearAll() {
|
||||
if (!confirm("Delete all history? This can't be undone.")) return;
|
||||
const ids = history.map((entry) => entry.id);
|
||||
history.splice(0);
|
||||
saveHistory();
|
||||
expandedId = null;
|
||||
stopPlayback();
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await invoke("delete_transcript", { id: String(id) });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn("clearAll: SQLite delete failed", id, err);
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
toasts.error(
|
||||
"Some history entries could not be deleted",
|
||||
`${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
@@ -307,12 +335,18 @@
|
||||
}
|
||||
|
||||
async function openViewer(item) {
|
||||
// Hand off the transcript ID only — the viewer window fetches the full
|
||||
// row from SQLite via `get_transcript`. Previously the entire DTO
|
||||
// (text + segments + tags) was stuffed into localStorage, putting
|
||||
// potentially MB-scale PII in a place readable by any same-origin
|
||||
// script.
|
||||
const handoff = JSON.stringify({ id: String(item.id) });
|
||||
try {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_item", handoff);
|
||||
localStorage.setItem("kon_viewer_mode", "view");
|
||||
await invoke("open_viewer_window");
|
||||
} catch {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_item", handoff);
|
||||
localStorage.setItem("kon_viewer_mode", "view");
|
||||
window.open("/viewer", "_blank", "width=600,height=700");
|
||||
}
|
||||
@@ -330,35 +364,156 @@
|
||||
return;
|
||||
}
|
||||
item.manualTags = [...(item.manualTags || []), next];
|
||||
saveHistory();
|
||||
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function removeManualTag(item, tag) {
|
||||
const t = normaliseTag(tag);
|
||||
item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t);
|
||||
saveHistory();
|
||||
saveTranscriptMeta(item.id, { manualTags: item.manualTags });
|
||||
}
|
||||
|
||||
// Phase 9 LLM content tagging.
|
||||
let tagging = $state(new Set());
|
||||
let bulkTagging = $state(false);
|
||||
let bulkTaggingProgress = $state("");
|
||||
|
||||
async function tagRow(item) {
|
||||
if (tagging.has(item.id)) return;
|
||||
const next = new Set(tagging);
|
||||
next.add(item.id);
|
||||
tagging = next;
|
||||
try {
|
||||
const result = await invoke("extract_content_tags_cmd", {
|
||||
transcript: item.text || "",
|
||||
});
|
||||
const r = result;
|
||||
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
|
||||
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
|
||||
} catch (err) {
|
||||
toasts.error("Tagging failed", String(err));
|
||||
} finally {
|
||||
const rest = new Set(tagging);
|
||||
rest.delete(item.id);
|
||||
tagging = rest;
|
||||
}
|
||||
}
|
||||
|
||||
function promoteLlmTag(item, tag) {
|
||||
const normalised = normaliseTag(tag);
|
||||
const manual = new Set((item.manualTags || []).map(normaliseTag));
|
||||
manual.add(normalised);
|
||||
item.manualTags = [...manual];
|
||||
item.llmTags = (item.llmTags || []).filter((t) => normaliseTag(t) !== normalised);
|
||||
saveTranscriptMeta(item.id, {
|
||||
manualTags: item.manualTags,
|
||||
llmTags: item.llmTags,
|
||||
});
|
||||
}
|
||||
|
||||
async function tagAllUntagged() {
|
||||
if (bulkTagging) return;
|
||||
bulkTagging = true;
|
||||
try {
|
||||
const untagged = history.filter((i) => !i.llmTags || i.llmTags.length === 0);
|
||||
let done = 0;
|
||||
for (const item of untagged) {
|
||||
done += 1;
|
||||
bulkTaggingProgress = `${done} / ${untagged.length}`;
|
||||
try {
|
||||
const result = await invoke("extract_content_tags_cmd", {
|
||||
transcript: item.text || "",
|
||||
});
|
||||
const r = result;
|
||||
item.llmTags = [`topic:${r.topic}`, `intent:${r.intent}`];
|
||||
await saveTranscriptMeta(item.id, { llmTags: item.llmTags });
|
||||
} catch (err) {
|
||||
console.error("bulk tag failed for", item.id, err);
|
||||
}
|
||||
}
|
||||
toasts.success(`Tagged ${untagged.length} transcript${untagged.length === 1 ? "" : "s"}`);
|
||||
} finally {
|
||||
bulkTagging = false;
|
||||
bulkTaggingProgress = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMarkdown(item) {
|
||||
const md = buildMarkdown(item);
|
||||
try {
|
||||
await navigator.clipboard.writeText(md);
|
||||
} catch {
|
||||
try {
|
||||
await invoke("copy_to_clipboard", { text: md });
|
||||
} catch {}
|
||||
}
|
||||
toasts.info("Markdown copied to clipboard — paste into Obsidian or save as .md");
|
||||
await saveTranscriptAsMarkdown(item);
|
||||
}
|
||||
|
||||
// Phase 9 bulk selection handlers.
|
||||
function toggleSelected(id) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected = new Set();
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
selected = new Set(filtered.map((i) => i.id));
|
||||
}
|
||||
|
||||
let selectedItems = $derived(history.filter((i) => selected.has(i.id)));
|
||||
|
||||
async function bulkExport() {
|
||||
const items = selectedItems;
|
||||
if (items.length === 0) return;
|
||||
await exportTranscriptsToDir(items);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
if (selected.size === 0) return;
|
||||
const yes = confirm(
|
||||
`Delete ${selected.size} transcript${selected.size === 1 ? "" : "s"}?`,
|
||||
);
|
||||
if (!yes) return;
|
||||
for (const id of selected) deleteFromHistoryById(id);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// Phase 9 keyboard shortcuts: Escape clears selection, Cmd/Ctrl+A
|
||||
// selects all visible when the History list owns focus. The
|
||||
// listEl?.contains check prevents hijacking Cmd+A inside text inputs
|
||||
// outside the list (e.g. the search box, inline title input).
|
||||
$effect(() => {
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape" && selected.size > 0) {
|
||||
clearSelection();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if ((e.key === "a" || e.key === "A") && (e.metaKey || e.ctrlKey)) {
|
||||
const target = document.activeElement;
|
||||
const inText =
|
||||
target &&
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA");
|
||||
if (!inText && listEl && listEl.contains(target)) {
|
||||
selectAllVisible();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
async function openEditor(item) {
|
||||
// Hand off the transcript ID only; the viewer hydrates from SQLite
|
||||
// (see openViewer above for the rationale).
|
||||
const handoff = JSON.stringify({ id: String(item.id) });
|
||||
try {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_item", handoff);
|
||||
localStorage.setItem("kon_viewer_mode", "edit");
|
||||
await invoke("open_viewer_window");
|
||||
} catch {
|
||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
||||
localStorage.setItem("kon_viewer_item", handoff);
|
||||
localStorage.setItem("kon_viewer_mode", "edit");
|
||||
window.open("/viewer", "_blank", "width=600,height=700");
|
||||
}
|
||||
@@ -399,6 +554,17 @@
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span class="text-[11px]">Starred</span>
|
||||
</button>
|
||||
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover disabled:opacity-50 inline-flex items-center gap-1.5"
|
||||
onclick={tagAllUntagged}
|
||||
disabled={bulkTagging}
|
||||
title="Run AI content tagging across every untagged transcript"
|
||||
>
|
||||
<Tag size={13} aria-hidden="true" />
|
||||
<span class="text-[11px]">{bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if history.length > 0}
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
@@ -415,7 +581,7 @@
|
||||
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
|
||||
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary"
|
||||
placeholder="Search all transcripts... (try tag:meetings)"
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
@@ -458,6 +624,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Phase 9 bulk-action toolbar. Surfaces only when selection is
|
||||
non-empty, so the unselected default state is unchanged. -->
|
||||
{#if selected.size > 0}
|
||||
<div
|
||||
class="mx-7 mb-3 flex items-center gap-3 px-4 py-2 rounded-lg border border-border-subtle bg-bg-elevated text-[12px]"
|
||||
role="toolbar"
|
||||
aria-label="Bulk actions for selected transcripts"
|
||||
>
|
||||
<span class="text-text-tertiary">{selected.size} selected</span>
|
||||
<button
|
||||
class="text-text hover:underline"
|
||||
onclick={selectAllVisible}
|
||||
>Select all</button>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text"
|
||||
onclick={clearSelection}
|
||||
>Clear</button>
|
||||
<div class="ml-auto flex gap-4">
|
||||
<button
|
||||
class="text-text hover:underline"
|
||||
onclick={bulkExport}
|
||||
>Export selected</button>
|
||||
<button
|
||||
class="text-danger hover:underline"
|
||||
onclick={bulkDelete}
|
||||
>Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- History list -->
|
||||
<div class="flex-1 px-7 pb-4 min-h-0">
|
||||
<Card classes="h-full flex flex-col overflow-hidden">
|
||||
@@ -473,7 +669,7 @@
|
||||
<div class="absolute left-0 right-0" style="top: {top}px; min-height: {height}px">
|
||||
<!-- Compact row -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle bg-bg-card hover:bg-hover cursor-pointer"
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle bg-bg-card hover:bg-hover cursor-pointer {selected.has(item.id) ? 'bg-accent/5' : ''}"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={() => toggleExpand(item.id)}
|
||||
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpand(item.id); } }}
|
||||
@@ -481,6 +677,18 @@
|
||||
tabindex="0"
|
||||
aria-expanded={expandedId === item.id}
|
||||
>
|
||||
<!-- Phase 9 bulk-select checkbox. Stays unobtrusive
|
||||
until checked or hovered; e.stopPropagation keeps
|
||||
the click off the row-expand toggle. -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-3.5 h-3.5 flex-shrink-0 accent-accent cursor-pointer opacity-70 hover:opacity-100 {selected.has(item.id) ? 'opacity-100' : ''}"
|
||||
aria-label={`Select transcript ${item.title || item.id}`}
|
||||
checked={selected.has(item.id)}
|
||||
onclick={(e) => { e.stopPropagation(); toggleSelected(item.id); }}
|
||||
onkeydown={(e) => { if (e.key === " " || e.key === "Enter") e.stopPropagation(); }}
|
||||
/>
|
||||
|
||||
<!-- Play button (if audio exists) -->
|
||||
{#if item.audioPath}
|
||||
<button
|
||||
@@ -578,7 +786,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
|
||||
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent mb-3"
|
||||
text-text placeholder:text-text-tertiary focus:border-accent mb-3"
|
||||
placeholder="Name this transcript..."
|
||||
value={item.title || ""}
|
||||
oninput={(e) => { item.title = e.target.value; }}
|
||||
@@ -606,10 +814,21 @@
|
||||
>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<!-- Phase 9 LLM-generated tags. Dashed border + italic
|
||||
distinguishes from manual chips. Click to promote
|
||||
into manualTags. -->
|
||||
{#each (item.llmTags || []) as t (t)}
|
||||
<button
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] italic border border-dashed border-border-subtle text-text-tertiary hover:text-text hover:border-accent"
|
||||
onclick={() => promoteLlmTag(item, t)}
|
||||
title="Click to keep as a manual tag"
|
||||
aria-label={`Promote ${t} to manual tag`}
|
||||
>{t}</button>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
|
||||
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[110px]"
|
||||
text-text placeholder:text-text-tertiary focus:border-accent w-[110px]"
|
||||
placeholder="+ add tag"
|
||||
onkeydown={(e) => handleAddTagKey(e, item)}
|
||||
data-no-transition
|
||||
@@ -641,6 +860,17 @@
|
||||
onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
|
||||
title="Export this transcript as a Markdown file with YAML frontmatter"
|
||||
>Export .md</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text disabled:opacity-50"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={(e) => { e.stopPropagation(); tagRow(item); }}
|
||||
disabled={tagging.has(item.id)}
|
||||
title="Generate AI content tags (topic and intent)"
|
||||
aria-label="Generate content tags with AI"
|
||||
>
|
||||
<Tag size={11} aria-hidden="true" />
|
||||
{tagging.has(item.id) ? "Tagging…" : "Tag"}
|
||||
</button>
|
||||
{#if item.audioPath && item.segments && item.segments.length > 0}
|
||||
<button
|
||||
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import Toggle from "$lib/components/Toggle.svelte";
|
||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
|
||||
import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte";
|
||||
import SettingsGroup from "$lib/components/SettingsGroup.svelte";
|
||||
import ZonePicker from "$lib/components/ZonePicker.svelte";
|
||||
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
|
||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
@@ -389,8 +391,14 @@
|
||||
let showNewProfile = $state(false);
|
||||
let showNewTemplate = $state(false);
|
||||
|
||||
// Accordion state — first section open by default
|
||||
let openSection = $state('transcription');
|
||||
// Phase 9c: SettingsGroup native <details> drives disclosure state per
|
||||
// group; no more centralised openSection. Transcription stays open by
|
||||
// default (matches the prior accordion behaviour where it was the
|
||||
// landing section).
|
||||
|
||||
// Search filter (Phase 9c). Empty string = no filter; non-empty string
|
||||
// pre-opens any group whose title/keywords match (case-insensitive).
|
||||
let settingsSearch = $state("");
|
||||
let settingsTextFont = $derived(pretextFontShorthand(prefs.accessibility, 11));
|
||||
let settingsPathLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 11));
|
||||
let outputFolderPreview = $derived.by(() => {
|
||||
@@ -624,15 +632,96 @@
|
||||
: "Selected model changed. Download it to enable AI features.";
|
||||
}
|
||||
|
||||
async function toggleAiSection() {
|
||||
openSection = openSection === 'ai' ? null : 'ai';
|
||||
if (openSection === 'ai') {
|
||||
await ensureRecommendedLlmTier();
|
||||
await refreshLlmStatus();
|
||||
await refreshGlobalLlmStatus(settings.aiTier);
|
||||
// AI section open-handler (Phase 9c restructure). Wired to the AI &
|
||||
// Processing SettingsGroup's `onopen`. The status calls are also made
|
||||
// in onMount, so re-running them on first open is a cheap correction
|
||||
// for cases where the mount-time call raced an unmounted state.
|
||||
async function onAiSectionOpen() {
|
||||
await ensureRecommendedLlmTier();
|
||||
await refreshLlmStatus();
|
||||
await refreshGlobalLlmStatus(settings.aiTier);
|
||||
}
|
||||
|
||||
// Phase 4 Read Page Aloud. Voices are lazy-loaded on first open so
|
||||
// Settings doesn't pay the `say -v ?` / PowerShell cost on every
|
||||
// mount. An empty list renders as "System default" only.
|
||||
let ttsVoices = $state([]);
|
||||
let ttsVoicesLoaded = $state(false);
|
||||
let ttsVoicesLoading = $state(false);
|
||||
let ttsVoicesError = $state("");
|
||||
|
||||
async function refreshTtsVoices() {
|
||||
if (ttsVoicesLoading) return;
|
||||
ttsVoicesLoading = true;
|
||||
ttsVoicesError = "";
|
||||
try {
|
||||
ttsVoices = await invoke("tts_list_voices");
|
||||
ttsVoicesLoaded = true;
|
||||
} catch (err) {
|
||||
ttsVoicesError = String(err);
|
||||
} finally {
|
||||
ttsVoicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wired to the Read aloud SettingsGroup's `onopen`. Idempotent —
|
||||
// refreshTtsVoices already short-circuits when ttsVoicesLoading.
|
||||
async function onReadAloudOpen() {
|
||||
if (!ttsVoicesLoaded) {
|
||||
await refreshTtsVoices();
|
||||
}
|
||||
}
|
||||
|
||||
async function testReadAloudVoice() {
|
||||
try {
|
||||
await invoke("tts_speak", {
|
||||
text: "This is Corbie reading aloud.",
|
||||
rate: settings.ttsRate,
|
||||
voice: settings.ttsVoice ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
toasts.warn("Could not read aloud", String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5 rituals. Autostart state mirrors the OS-level entry managed
|
||||
// by tauri-plugin-autostart; reading via invoke keeps the toggle
|
||||
// honest even if the user has edited their .desktop file manually.
|
||||
let autostartSyncing = $state(false);
|
||||
|
||||
async function setLaunchAtLogin(nextOn: boolean) {
|
||||
autostartSyncing = true;
|
||||
try {
|
||||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||
if (nextOn) {
|
||||
await plugin.enable();
|
||||
} else {
|
||||
await plugin.disable();
|
||||
}
|
||||
settings.launchAtLogin = nextOn;
|
||||
} catch (err) {
|
||||
toasts.warn("Could not update autostart", String(err));
|
||||
// Re-read to correct the UI if we failed halfway.
|
||||
try {
|
||||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||
settings.launchAtLogin = await plugin.isEnabled();
|
||||
} catch { /* best-effort */ }
|
||||
} finally {
|
||||
autostartSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAutostartFromOs() {
|
||||
try {
|
||||
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||
settings.launchAtLogin = await plugin.isEnabled();
|
||||
} catch { /* best-effort on browser / first load */ }
|
||||
}
|
||||
|
||||
function openWindDown() {
|
||||
page.current = "shutdown";
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshRuntimeCapabilities();
|
||||
@@ -651,6 +740,10 @@
|
||||
// the user opens the Audio section.
|
||||
refreshAudioDevices();
|
||||
|
||||
// Phase 5: read the live OS-level autostart state so the toggle
|
||||
// reflects reality rather than last-saved intent.
|
||||
syncAutostartFromOs();
|
||||
|
||||
// Vocabulary is loaded reactively via the $effect that tracks the
|
||||
// active profile id (set once profilesStore.load() resolves in the
|
||||
// root layout). No eager fetch needed here.
|
||||
@@ -866,17 +959,21 @@
|
||||
|
||||
<div class="px-7 pb-8">
|
||||
<Card>
|
||||
<!-- Audio (microphone selection) -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'audio' ? null : 'audio'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Audio</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'audio' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'audio'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!--
|
||||
Phase 9c progressive-disclosure restructure. Seven top-level
|
||||
groups (Audio, Vocabulary, Transcription, AI & Processing,
|
||||
Tasks & Rituals, Output & Capture, Appearance & System), each
|
||||
wrapped in <SettingsGroup>. Sub-sections inside a group are
|
||||
nested <SettingsGroup>s where there is more than one; otherwise
|
||||
the group renders its content directly.
|
||||
-->
|
||||
|
||||
<!-- 1. Audio — input device, gain, monitoring, RMS/VAD -->
|
||||
<SettingsGroup
|
||||
title="Audio"
|
||||
description="Microphone input and capture behaviour."
|
||||
>
|
||||
<div class="px-1 pb-2 animate-fade-in">
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Microphone</p>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -911,22 +1008,21 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Vocabulary — profile-scoped (Task 15) -->
|
||||
<!-- Terms and the initial prompt travel with the active profile.
|
||||
The default profile can't be deleted, only edited. -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'vocabulary' ? null : 'vocabulary'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Vocabulary</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'vocabulary' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'vocabulary'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!--
|
||||
2. Vocabulary — custom dictionary, hotwords, abbreviation
|
||||
expansion. Holds two sub-sections: profile-scoped vocabulary
|
||||
(terms + initial prompt; the default profile can't be deleted)
|
||||
and the older Profiles & Templates manager retained for back-
|
||||
compat with existing user data.
|
||||
-->
|
||||
<SettingsGroup
|
||||
title="Vocabulary"
|
||||
description="Profile-scoped terms, prompts, and templates."
|
||||
>
|
||||
<SettingsGroup title="Terms & profiles" open>
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[12px] text-text-tertiary mb-4">
|
||||
Words and phrases the AI cleanup pass should preserve exactly. Useful for medication names, place names, jargon, names of people in your support network, anything Whisper tends to mishear. Vocabulary is scoped to a profile — switch profiles to swap whole vocabularies.
|
||||
</p>
|
||||
@@ -1134,20 +1230,158 @@
|
||||
<p class="text-[11px] text-text-tertiary italic">Loading profiles…</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
<!--
|
||||
Older Profiles & Templates manager. Kept inside the Vocabulary
|
||||
group because both sub-sections concern dictation vocabulary
|
||||
and per-profile content.
|
||||
-->
|
||||
<SettingsGroup title="Profiles & templates">
|
||||
<div class="animate-fade-in">
|
||||
<!-- Profiles section -->
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left mb-1"
|
||||
onclick={() => showProfiles = !showProfiles}
|
||||
>
|
||||
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showProfiles ? 'rotate-90' : ''}" />
|
||||
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{profiles.length}
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
|
||||
|
||||
<!-- Transcription -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'transcription' ? null : 'transcription'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Transcription</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'transcription' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'transcription'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
{#if showProfiles}
|
||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
||||
{#each profiles as profile, i}
|
||||
<div class="group">
|
||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||
<span class="text-[12px] text-text flex-1 truncate">{profile.name}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{profileWordCount(profile.words)} words
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingProfile = editingProfile === i ? -1 : i}
|
||||
>{editingProfile === i ? "Close" : "Edit"}</button>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteProfile(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{#if editingProfile === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
<textarea
|
||||
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||
text-[12px] text-text resize-none
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One word or phrase per line..."
|
||||
bind:value={profile.words}
|
||||
oninput={() => saveProfiles()}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if showNewProfile}
|
||||
<div class="flex items-center gap-2 animate-fade-in">
|
||||
<input
|
||||
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Profile name..."
|
||||
bind:value={newProfileName}
|
||||
onkeydown={(e) => e.key === "Enter" && createProfile()}
|
||||
data-no-transition
|
||||
/>
|
||||
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
|
||||
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewProfile = false; newProfileName = ""; }}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-border-subtle"></div>
|
||||
|
||||
<!-- Templates section -->
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left mb-1"
|
||||
onclick={() => showTemplates = !showTemplates}
|
||||
>
|
||||
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showTemplates ? 'rotate-90' : ''}" />
|
||||
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{templates.length}
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
|
||||
|
||||
{#if showTemplates}
|
||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
||||
{#each templates as template, i}
|
||||
<div class="group">
|
||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||
<span class="text-[12px] text-text flex-1 truncate">{template.name}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{template.sections.length} sections
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
|
||||
>{editingTemplate === i ? "Close" : "Edit"}</button>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteTemplate(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{#if editingTemplate === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
<textarea
|
||||
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||
text-[12px] text-text resize-none
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One section per line..."
|
||||
value={template.sections.join("\n")}
|
||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if showNewTemplate}
|
||||
<div class="flex items-center gap-2 animate-fade-in">
|
||||
<input
|
||||
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Template name..."
|
||||
bind:value={newTemplateName}
|
||||
onkeydown={(e) => e.key === "Enter" && createTemplate()}
|
||||
data-no-transition
|
||||
/>
|
||||
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
|
||||
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewTemplate = false; newTemplateName = ""; }}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- 3. Transcription — engine, language, model, compute device, format mode. -->
|
||||
<SettingsGroup
|
||||
title="Transcription"
|
||||
description="Engine selection, models, language, and output formatting."
|
||||
open
|
||||
>
|
||||
<div class="animate-fade-in">
|
||||
|
||||
<!-- Engine selector -->
|
||||
<div class="mb-6">
|
||||
@@ -1325,20 +1559,20 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Processing -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'processing' ? null : 'processing'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Processing</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'processing' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'processing'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!--
|
||||
4. AI & Processing — local LLM provider/model, content tagging,
|
||||
task extraction, deterministic post-processing toggles, and the
|
||||
if-then implementation rules editor.
|
||||
-->
|
||||
<SettingsGroup
|
||||
title="AI & Processing"
|
||||
description="Local LLM tier, cleanup post-processing, and rules."
|
||||
onopen={onAiSectionOpen}
|
||||
>
|
||||
<SettingsGroup title="Post-processing">
|
||||
<div class="animate-fade-in">
|
||||
<div class="space-y-0.5">
|
||||
<Toggle
|
||||
bind:checked={settings.removeFillers}
|
||||
@@ -1352,20 +1586,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- AI Assistant -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={toggleAiSection}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">AI Assistant</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'ai' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'ai'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<SettingsGroup title="AI Assistant" description="Local LLM tier and model management.">
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
Local LLM for transcript cleanup, smart task extraction, and task breakdown. Runs fully offline after the model is downloaded.
|
||||
</p>
|
||||
@@ -1557,169 +1781,236 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Toggle
|
||||
bind:checked={settings.prewarmModelOnStartup}
|
||||
label="Prewarm transcription model on startup"
|
||||
description="Loads Whisper after the app opens. Faster first capture, but higher idle memory use."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Profiles & Templates -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'profiles' ? null : 'profiles'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Profiles & Templates</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'profiles' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'profiles'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!-- Profiles section -->
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left mb-1"
|
||||
onclick={() => showProfiles = !showProfiles}
|
||||
>
|
||||
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showProfiles ? 'rotate-90' : ''}" />
|
||||
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{profiles.length}
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
|
||||
<SettingsGroup title="If-then rules" description="Implementation rules for the AI cleanup pass.">
|
||||
<div class="animate-fade-in">
|
||||
<ImplementationRulesEditor />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsGroup>
|
||||
|
||||
{#if showProfiles}
|
||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
||||
{#each profiles as profile, i}
|
||||
<div class="group">
|
||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||
<span class="text-[12px] text-text flex-1 truncate">{profile.name}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{profileWordCount(profile.words)} words
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingProfile = editingProfile === i ? -1 : i}
|
||||
>{editingProfile === i ? "Close" : "Edit"}</button>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteProfile(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{#if editingProfile === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
<textarea
|
||||
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||
text-[12px] text-text resize-none
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One word or phrase per line..."
|
||||
bind:value={profile.words}
|
||||
oninput={() => saveProfiles()}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<!--
|
||||
5. Tasks & Rituals — task list management, focus timer, sparkline
|
||||
display, morning triage, evening wind-down, nudges. Launch-at-
|
||||
login retained inside Rituals because it's bundled with the
|
||||
morning/evening rituals stack in the existing UI block.
|
||||
-->
|
||||
<SettingsGroup
|
||||
title="Tasks & Rituals"
|
||||
description="Task surface, gamification, rituals, and nudges."
|
||||
>
|
||||
<SettingsGroup title="Rituals" description="Morning triage, wind-down, launch at login.">
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
All off by default. Rituals only appear when you ask for them.
|
||||
</p>
|
||||
|
||||
{#if showNewProfile}
|
||||
<div class="flex items-center gap-2 animate-fade-in">
|
||||
<input
|
||||
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Profile name..."
|
||||
bind:value={newProfileName}
|
||||
onkeydown={(e) => e.key === "Enter" && createProfile()}
|
||||
data-no-transition
|
||||
/>
|
||||
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
|
||||
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewProfile = false; newProfileName = ""; }}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
|
||||
{/if}
|
||||
<Toggle
|
||||
bind:checked={settings.ritualsMorning}
|
||||
label="Morning triage"
|
||||
description="On the first launch of the day after your set time, show a gentle pick-three modal drawn from open tasks. Skip any day without penalty."
|
||||
/>
|
||||
|
||||
{#if settings.ritualsMorning}
|
||||
<div class="pl-1 py-3 animate-fade-in">
|
||||
<label for="triage-time" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
|
||||
Earliest triage time
|
||||
</label>
|
||||
<input
|
||||
id="triage-time"
|
||||
type="time"
|
||||
class="bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||
bind:value={settings.ritualsMorningTime}
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
ADHD sleep inertia is intense for the first 30–45 minutes after waking. Pick a time when you're genuinely ready to decide.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-border-subtle"></div>
|
||||
<Toggle
|
||||
bind:checked={settings.ritualsEvening}
|
||||
label="Evening wind-down"
|
||||
description="A reflective page you can open when you want to close the day. Not scheduled, never nagging."
|
||||
/>
|
||||
|
||||
<!-- Templates section -->
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left mb-1"
|
||||
onclick={() => showTemplates = !showTemplates}
|
||||
>
|
||||
<ChevronRight class="w-3 h-3 text-text-tertiary transition-transform duration-[var(--duration-ui)] {showTemplates ? 'rotate-90' : ''}" />
|
||||
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{templates.length}
|
||||
</span>
|
||||
</button>
|
||||
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
|
||||
{#if settings.ritualsEvening}
|
||||
<div class="pl-1 py-3 animate-fade-in">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
|
||||
onclick={openWindDown}
|
||||
>
|
||||
Open wind-down now
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showTemplates}
|
||||
<div class="space-y-1.5 animate-fade-in ml-5">
|
||||
{#each templates as template, i}
|
||||
<div class="group">
|
||||
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
|
||||
<span class="text-[12px] text-text flex-1 truncate">{template.name}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||
{template.sections.length} sections
|
||||
</span>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
|
||||
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
|
||||
>{editingTemplate === i ? "Close" : "Edit"}</button>
|
||||
<button
|
||||
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
|
||||
onclick={() => deleteTemplate(i)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
{#if editingTemplate === i}
|
||||
<div class="mt-1.5 animate-fade-in">
|
||||
<textarea
|
||||
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
|
||||
text-[12px] text-text resize-none
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One section per line..."
|
||||
value={template.sections.join("\n")}
|
||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mt-4 pt-4 border-t border-border-subtle">
|
||||
<!-- One-way flow: click → OS call → state update. Can't use
|
||||
the standard Toggle because its bind:checked would
|
||||
race the autostart invoke and let the UI lie during
|
||||
the round-trip. -->
|
||||
<div class="flex items-start gap-3 py-2.5">
|
||||
<button
|
||||
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
|
||||
{settings.launchAtLogin ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-bg-elevated'}
|
||||
active:scale-95 disabled:opacity-60"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={() => setLaunchAtLogin(!settings.launchAtLogin)}
|
||||
disabled={autostartSyncing}
|
||||
role="switch"
|
||||
aria-checked={settings.launchAtLogin}
|
||||
aria-label="Launch Corbie at login"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
||||
{settings.launchAtLogin ? 'translate-x-[16px]' : 'translate-x-0'}"
|
||||
style="transition: transform var(--duration-ui) cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text leading-tight">Launch Corbie at login</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">
|
||||
So Corbie is already there at the start of the day. Uses your OS's standard autostart — no background tricks.
|
||||
</p>
|
||||
{#if autostartSyncing}
|
||||
<p class="text-[11px] text-text-tertiary mt-1">Updating…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showNewTemplate}
|
||||
<div class="flex items-center gap-2 animate-fade-in">
|
||||
<input
|
||||
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder="Template name..."
|
||||
bind:value={newTemplateName}
|
||||
onkeydown={(e) => e.key === "Enter" && createTemplate()}
|
||||
data-no-transition
|
||||
/>
|
||||
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
|
||||
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewTemplate = false; newTemplateName = ""; }}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Tasks page" description="Gamification visuals on the Tasks header.">
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
How the Tasks page surfaces progress. Always additive.
|
||||
</p>
|
||||
|
||||
<!-- Phase 8: forgiving gamification. Toggle controls the sparkline only.
|
||||
The "N today" badge on the Tasks header is always on. -->
|
||||
<Toggle
|
||||
bind:checked={settings.showMomentumSparkline}
|
||||
label="Show momentum sparkline"
|
||||
description="A tiny chart of the last 7 days' completion counts, shown on the Tasks header. Never counts against you."
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Nudges" description="Soft notifications, capped per hour.">
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
|
||||
</p>
|
||||
|
||||
<Toggle
|
||||
bind:checked={settings.nudgesEnabled}
|
||||
label="Enable nudges"
|
||||
description="Soft-touch notifications when a timer has been ticking while you're away, when a morning triage is waiting, or when a micro-step decomposition has sat untouched for 15 minutes."
|
||||
/>
|
||||
|
||||
{#if settings.nudgesEnabled}
|
||||
<div class="pl-1 py-1 animate-fade-in">
|
||||
<Toggle
|
||||
bind:checked={settings.nudgesMuted}
|
||||
label="Mute for now"
|
||||
description="Stops delivery without forgetting your settings. Flip back off when you want the nudges back."
|
||||
/>
|
||||
<Toggle
|
||||
bind:checked={settings.nudgesSpeakAloud}
|
||||
label="Speak nudges aloud"
|
||||
description="Also reads the nudge out through your Read-aloud voice. Useful if you keep the sound off but look up sometimes."
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'output' ? null : 'output'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Output</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'output' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'output'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!--
|
||||
6. Output & Capture — clipboard, paste, sound cues, history
|
||||
retention, dictation drafts, and Read aloud (TTS).
|
||||
-->
|
||||
<SettingsGroup
|
||||
title="Output & Capture"
|
||||
description="Clipboard, paste, audio capture, and read-aloud."
|
||||
>
|
||||
<SettingsGroup title="Read aloud" description="System TTS voice for read-aloud features." onopen={onReadAloudOpen}>
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
Uses your operating system's built-in voices. No audio leaves the machine.
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="tts-voice" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Voice</label>
|
||||
<select
|
||||
id="tts-voice"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||
value={settings.ttsVoice ?? ""}
|
||||
onchange={(e) => { settings.ttsVoice = e.currentTarget.value || null; }}
|
||||
disabled={ttsVoicesLoading}
|
||||
>
|
||||
<option value="">System default</option>
|
||||
{#each ttsVoices as voice (voice.id)}
|
||||
<option value={voice.id}>
|
||||
{voice.name}{#if voice.language} · {voice.language}{/if}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if ttsVoicesError}
|
||||
<p class="text-[11px] text-danger mt-2">{ttsVoicesError}</p>
|
||||
{:else if ttsVoicesLoaded && ttsVoices.length === 0}
|
||||
<p class="text-[11px] text-text-tertiary mt-2">
|
||||
No additional voices reported by the system synth. Install extra voices through your OS accessibility settings.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="tts-rate" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
|
||||
Rate · {settings.ttsRate.toFixed(1)}×
|
||||
</label>
|
||||
<input
|
||||
id="tts-rate"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
class="w-full accent-accent"
|
||||
bind:value={settings.ttsRate}
|
||||
/>
|
||||
<div class="flex justify-between text-[10px] text-text-tertiary mt-1">
|
||||
<span>Slower</span>
|
||||
<span>Normal</span>
|
||||
<span>Faster</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
|
||||
onclick={testReadAloudVoice}
|
||||
>
|
||||
Test voice
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Capture & export" description="Clipboard, paste, history, and dictation drafts." open>
|
||||
<div class="animate-fade-in">
|
||||
<div class="space-y-0.5">
|
||||
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
|
||||
<Toggle
|
||||
@@ -1817,37 +2108,27 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Hotkey -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'hotkey' ? null : 'hotkey'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Global Hotkey</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'hotkey' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'hotkey'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<!--
|
||||
7. Appearance & System — theme, dark mode, locale, font size,
|
||||
accessibility controls, global hotkey, autostart-adjacent
|
||||
platform settings, and About / diagnostics.
|
||||
-->
|
||||
<SettingsGroup
|
||||
title="Appearance & System"
|
||||
description="Theme, accessibility, hotkey, and About."
|
||||
>
|
||||
<SettingsGroup title="Global hotkey" description="Toggle recording from anywhere.">
|
||||
<div class="animate-fade-in">
|
||||
<p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p>
|
||||
<HotkeyRecorder />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Appearance -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'appearance' ? null : 'appearance'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Appearance</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'appearance' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'appearance'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<SettingsGroup title="Appearance" description="Theme, zone, font size, locale.">
|
||||
<div class="animate-fade-in">
|
||||
<div class="mb-6">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p>
|
||||
<SegmentedButton options={["Dark", "Light", "System"]} bind:value={settings.theme} />
|
||||
@@ -1889,36 +2170,16 @@
|
||||
<p class="text-[11px] text-text-tertiary mt-2">{$_("settings.languageDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- Accessibility -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'accessibility' ? null : 'accessibility'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">Accessibility</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'accessibility' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'accessibility'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<SettingsGroup title="Accessibility" description="Reduced motion, contrast, typography.">
|
||||
<div class="animate-fade-in">
|
||||
<AccessibilityControls />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<!-- About -->
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'about' ? null : 'about'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">About</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'about' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'about'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<SettingsGroup title="About" description="Engine status and diagnostic report.">
|
||||
<div class="animate-fade-in">
|
||||
<!-- Engine status -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="w-[7px] h-[7px] rounded-full {engineOk ? 'bg-success' : 'bg-warning'}"></span>
|
||||
@@ -1982,8 +2243,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsGroup>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
169
src/lib/pages/ShutdownRitualPage.svelte
Normal file
169
src/lib/pages/ShutdownRitualPage.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
// @ts-nocheck
|
||||
// Phase 5 evening wind-down. A reflective (not transactional) page
|
||||
// users trigger manually when they want to close the working day.
|
||||
//
|
||||
// Newport-style shutdown ritual: mechanical closure + physical reset +
|
||||
// intentional cue. Research on psychological detachment shows this
|
||||
// reduces evening rumination by ~40% (Simply Psychology / Cal Newport).
|
||||
// The Zeigarnik effect means unfinished tasks keep firing reminder
|
||||
// signals — naming them here, even without acting, is what silences
|
||||
// the loop.
|
||||
//
|
||||
// Copy rule: additive framing only ("You finished X"), never
|
||||
// subtractive ("X still open"). Open loops are listed but read-only —
|
||||
// transactional work belongs on the Tasks page.
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Moon, ArrowLeft } from 'lucide-svelte';
|
||||
import { page } from '$lib/stores/page.svelte.js';
|
||||
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
text: string;
|
||||
bucket: string;
|
||||
done: boolean;
|
||||
doneAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
let completedToday = $state<TaskRow[]>([]);
|
||||
let openLoops = $state<TaskRow[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
function localDateKey(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!hasTauriRuntime()) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const all = await invoke<TaskRow[]>('list_tasks_cmd');
|
||||
const today = todayKey();
|
||||
completedToday = all.filter((t) => t.done && localDateKey(t.doneAt) === today);
|
||||
openLoops = all.filter((t) => !t.done);
|
||||
} catch {
|
||||
completedToday = [];
|
||||
openLoops = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
page.current = 'dictation';
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full bg-bg overflow-y-auto">
|
||||
<div class="flex items-center gap-3 px-7 pt-6 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-lg text-text-tertiary hover:text-text hover:bg-hover"
|
||||
onclick={close}
|
||||
aria-label="Back to dictation"
|
||||
>
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<Moon size={18} class="text-text-secondary" aria-hidden="true" />
|
||||
<h1 class="font-display text-[26px] italic text-text">Wind down</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-7 pb-8 max-w-[640px]">
|
||||
{#if loading}
|
||||
<p class="text-[12px] text-text-tertiary py-6">Looking at today…</p>
|
||||
{:else}
|
||||
<!-- Additive framing: lead with what the user did, never with what they didn't. -->
|
||||
<section class="mb-8">
|
||||
<p class="font-display text-[18px] italic text-text mb-2">
|
||||
{#if completedToday.length === 0}
|
||||
A quiet day.
|
||||
{:else if completedToday.length === 1}
|
||||
You finished one thing today.
|
||||
{:else}
|
||||
You finished {completedToday.length} today.
|
||||
{/if}
|
||||
</p>
|
||||
{#if completedToday.length > 0}
|
||||
<ul class="mt-3 flex flex-col gap-1.5">
|
||||
{#each completedToday as task (task.id)}
|
||||
<li class="text-[13px] text-text-secondary leading-relaxed">
|
||||
<span class="text-success mr-2" aria-hidden="true">✓</span>{task.text}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Read-only reflection. The Tasks page is where things get done. -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Open loops</h2>
|
||||
{#if openLoops.length === 0}
|
||||
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
|
||||
{:else}
|
||||
<p class="text-[11px] text-text-tertiary mb-3">
|
||||
These are still here. Naming them silences the loop — you don't have to act now.
|
||||
</p>
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each openLoops.slice(0, 12) as task (task.id)}
|
||||
<li class="text-[12px] text-text-secondary truncate">
|
||||
{task.text}
|
||||
</li>
|
||||
{/each}
|
||||
{#if openLoops.length > 12}
|
||||
<li class="text-[11px] text-text-tertiary pt-1">
|
||||
…and {openLoops.length - 12} more.
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Physical reset + intentional cue. Evidence: Newport shutdown
|
||||
template, ~40% rumination reduction in psychological-detachment
|
||||
studies. -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Before you close</h2>
|
||||
<ol class="flex flex-col gap-2 text-[13px] text-text-secondary">
|
||||
<li>
|
||||
<span class="font-medium text-text">Take a breath.</span>
|
||||
Stand up. Stretch for ten seconds.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-medium text-text">Pick a closing line.</span>
|
||||
Say it out loud when you're ready — something like "I'm done for today."
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-medium text-text">Let it rest.</span>
|
||||
Tomorrow's list will be here. Work done for today.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div class="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg bg-accent text-white text-[12px] font-medium hover:bg-accent-hover"
|
||||
onclick={close}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,14 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import type { TaskBucket, TaskList } from "$lib/types/app";
|
||||
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
||||
setTaskEnergy,
|
||||
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
||||
settings, saveSettings,
|
||||
} from "$lib/stores/page.svelte.js";
|
||||
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
|
||||
import WipTaskList from '$lib/components/WipTaskList.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight } from 'lucide-svelte';
|
||||
import CompletionSparkline from "$lib/components/CompletionSparkline.svelte";
|
||||
import EnergyChip from '$lib/components/EnergyChip.svelte';
|
||||
import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap } from 'lucide-svelte';
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import { formatTimestamp } from "$lib/utils/time.js";
|
||||
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
||||
@@ -47,6 +52,17 @@
|
||||
return EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS] || effort;
|
||||
}
|
||||
|
||||
// Phase 3 energy sort: when the user has opted in and declared a
|
||||
// current energy, tasks matching that energy sort to the top. Tasks
|
||||
// with no energy tag are treated as Medium-equivalent, per the brief's
|
||||
// framing that unset is the normal case. Nothing is ever hidden —
|
||||
// "reserves" in the spec means de-prioritise, not filter.
|
||||
function energyMatchRank(task: TaskEntry, currentEnergy: EnergyLevel | null): number {
|
||||
if (!currentEnergy) return 0;
|
||||
const effective = task.energy ?? "medium";
|
||||
return effective === currentEnergy ? 0 : 1;
|
||||
}
|
||||
|
||||
let filteredTasks = $derived.by(() => {
|
||||
let list = tasks.filter((t) => !t.done);
|
||||
if (activeBucket !== "all") {
|
||||
@@ -68,9 +84,84 @@
|
||||
} else if (sortMode === "deep-first") {
|
||||
list.sort((a, b) => effortRank(b.effort) - effortRank(a.effort));
|
||||
}
|
||||
// Match-my-energy sort runs last (stable) so it reorders the result
|
||||
// of the effort-based sort rather than replacing it.
|
||||
if (settings.matchMyEnergy && settings.currentEnergy) {
|
||||
const energy = settings.currentEnergy;
|
||||
list.sort((a, b) => energyMatchRank(a, energy) - energyMatchRank(b, energy));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function cycleCurrentEnergy(next: EnergyLevel | null) {
|
||||
settings.currentEnergy = next;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function toggleMatchMyEnergy() {
|
||||
settings.matchMyEnergy = !settings.matchMyEnergy;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function energyLabel(level: EnergyLevel | null): string {
|
||||
switch (level) {
|
||||
case "high": return "High";
|
||||
case "medium": return "Medium";
|
||||
case "brain_dead": return "Brain-Dead";
|
||||
default: return "Not set";
|
||||
}
|
||||
}
|
||||
|
||||
// ARIA radiogroup keyboard handling for the energy segmented control.
|
||||
// Full W3C APG Radio Group pattern: arrow keys cycle, Home / End jump
|
||||
// to ends, focus and selection move together. The options array lives
|
||||
// here so the keyboard handler and the render loop share one source
|
||||
// of truth — desynchronising them is the usual way these patterns
|
||||
// rot over time.
|
||||
type EnergyOption = { value: EnergyLevel | null; label: string };
|
||||
const energyOptions: EnergyOption[] = [
|
||||
{ value: null, label: "—" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "medium", label: "Med" },
|
||||
{ value: "brain_dead", label: "Low" },
|
||||
];
|
||||
let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function selectEnergyByIndex(idx: number) {
|
||||
const clamped = Math.max(0, Math.min(energyOptions.length - 1, idx));
|
||||
const opt = energyOptions[clamped];
|
||||
cycleCurrentEnergy(opt.value);
|
||||
// Move focus to the newly-checked button so keyboard nav tracks
|
||||
// selection, per the ARIA radio pattern.
|
||||
const btn = energyRadioGroupEl?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[clamped];
|
||||
btn?.focus();
|
||||
}
|
||||
|
||||
function energyRadioKeydown(e: KeyboardEvent) {
|
||||
const current = energyOptions.findIndex((o) => o.value === settings.currentEnergy);
|
||||
const here = current < 0 ? 0 : current;
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
selectEnergyByIndex((here + 1) % energyOptions.length);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
selectEnergyByIndex((here - 1 + energyOptions.length) % energyOptions.length);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
selectEnergyByIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
selectEnergyByIndex(energyOptions.length - 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let completedTasks = $derived.by(() => {
|
||||
let list = tasks.filter((t) => t.done);
|
||||
if (activeBucket !== "all") {
|
||||
@@ -195,10 +286,79 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center px-7 pt-6 pb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-display text-text italic">Tasks</h2>
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="text-xl font-display text-text italic">Tasks</h2>
|
||||
|
||||
<!-- Phase 8 badge + sparkline. aria-live scoped to just this
|
||||
wrapper so screen readers announce completions without
|
||||
re-reading the rest of the header. -->
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if todayCount() > 0}
|
||||
<span
|
||||
class="text-[11px] text-text-tertiary badge-today"
|
||||
aria-label={`${todayCount()} ${todayCount() === 1 ? "task" : "tasks"} completed today`}
|
||||
>
|
||||
{todayCount()} today
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if settings.showMomentumSparkline}
|
||||
<CompletionSparkline data={recentCompletions} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-tertiary mt-1">Add tasks manually. Automatic extraction from your transcripts is coming.</p>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Phase 3 match-my-energy control. Three-state energy selector +
|
||||
toggle for the sort. Sits in the header so it is always visible
|
||||
from any bucket / list context. -->
|
||||
<div class="flex items-center gap-2 mr-2">
|
||||
<span class="text-[10px] text-text-tertiary hidden sm:inline" aria-hidden="true">I feel</span>
|
||||
<div
|
||||
bind:this={energyRadioGroupEl}
|
||||
class="flex items-center gap-0.5 bg-bg-input border border-border-subtle rounded-lg p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="My current energy"
|
||||
tabindex="-1"
|
||||
onkeydown={energyRadioKeydown}
|
||||
>
|
||||
{#each energyOptions as opt}
|
||||
{@const checked = settings.currentEnergy === opt.value}
|
||||
<button
|
||||
class="text-[10px] px-2 py-0.5 rounded-md
|
||||
{checked
|
||||
? 'bg-accent/25 text-accent border border-accent/30'
|
||||
: 'text-text-secondary hover:text-text'}"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={opt.value ? `Set current energy to ${energyLabel(opt.value)}` : 'Clear current energy'}
|
||||
tabindex={checked ? 0 : -1}
|
||||
onclick={() => cycleCurrentEnergy(opt.value)}
|
||||
>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1 btn-md rounded-lg text-[10px]
|
||||
{settings.matchMyEnergy
|
||||
? 'bg-accent/25 text-accent border border-accent/30'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text border border-transparent'}"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={toggleMatchMyEnergy}
|
||||
aria-pressed={settings.matchMyEnergy}
|
||||
aria-label={settings.matchMyEnergy ? 'Match my energy is on — click to turn off' : 'Match my energy is off — click to turn on'}
|
||||
title="Sort matching tasks to the top (unset counts as Medium)"
|
||||
>
|
||||
<Zap size={12} aria-hidden="true" />
|
||||
Match my energy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
@@ -217,7 +377,7 @@
|
||||
<Search size={14} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary focus:outline-none"
|
||||
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary"
|
||||
placeholder="Search tasks..."
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
@@ -235,7 +395,7 @@
|
||||
<Plus size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary focus:outline-none"
|
||||
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary"
|
||||
placeholder="Add a task to {activeListName}{activeBucket !== 'all' ? ` (${activeBucket})` : ''}... (Enter to save)"
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
@@ -298,11 +458,20 @@
|
||||
|
||||
<!-- Main content: sidebar + tasks -->
|
||||
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
|
||||
<!--
|
||||
Layout-transition perf fix: was animating `width` directly. Now wraps
|
||||
the list sidebar in a CSS-grid container whose `grid-template-columns`
|
||||
track-size transitions between collapsed/expanded widths. Same visual
|
||||
effect, single transitioning property the browser can handle as a
|
||||
track-spec change rather than a width re-flow each frame.
|
||||
-->
|
||||
<!-- List sidebar -->
|
||||
<div
|
||||
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden
|
||||
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
|
||||
style="transition: width var(--duration-ui)"
|
||||
class="grid {sidebarCollapsed ? 'grid-cols-[40px]' : 'grid-cols-[160px]'}"
|
||||
style="transition: grid-template-columns var(--duration-ui)"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Collapse toggle -->
|
||||
<button
|
||||
@@ -325,7 +494,7 @@
|
||||
<input
|
||||
bind:this={editingInputEl}
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
|
||||
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text"
|
||||
bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
|
||||
onblur={finishRenaming}
|
||||
@@ -385,7 +554,7 @@
|
||||
bind:this={newListInputEl}
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder:text-text-tertiary focus:border-accent"
|
||||
placeholder="List name..."
|
||||
bind:value={newListName}
|
||||
onkeydown={handleCreateList}
|
||||
@@ -401,6 +570,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
@@ -451,6 +621,13 @@
|
||||
onclick={() => setEffort(task.id, e)}
|
||||
>{effortLabel(e)}</button>
|
||||
{/each}
|
||||
<span class="text-border-subtle">·</span>
|
||||
<!-- Energy chip (Phase 3) -->
|
||||
<EnergyChip
|
||||
energy={task.energy}
|
||||
onSelect={(next) => setTaskEnergy(task.id, next)}
|
||||
size="md"
|
||||
/>
|
||||
<!-- Timestamp -->
|
||||
{#if task.createdAt}
|
||||
<span class="text-[10px] text-text-tertiary ml-auto">{formatTimestamp(task.createdAt)}</span>
|
||||
@@ -522,3 +699,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Phase 9 polish: gentle entrance animation when the today-count
|
||||
badge mounts (it is conditionally rendered, so each new render
|
||||
re-fires the animation). prefers-reduced-motion disables. */
|
||||
:global(.badge-today) {
|
||||
animation: badge-pop 180ms ease both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.badge-today) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@keyframes badge-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
59
src/lib/stores/completionStats.svelte.ts
Normal file
59
src/lib/stores/completionStats.svelte.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Phase 8 store for forgiving gamification. Owns the last-7-days series
|
||||
// of completion counts rendered by the Tasks-page badge + sparkline.
|
||||
//
|
||||
// Refresh triggers:
|
||||
// - module load (first time)
|
||||
// - kon:task-completed (from page.svelte.ts completeTask)
|
||||
// - kon:step-completed (from MicroSteps.svelte)
|
||||
// - kon:task-uncompleted (new event, emitted by uncompleteTask in Task 10)
|
||||
// - kon:task-deleted (new event, emitted by deleteTask in Task 10)
|
||||
// - window focus (for day rollover while the app stayed open
|
||||
// past midnight)
|
||||
//
|
||||
// We deliberately avoid polling. Every path that changes a row's
|
||||
// done state or deletes a row emits one of the events above.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
import type { DailyCompletionCount } from "$lib/types/app";
|
||||
|
||||
const DAYS = 7;
|
||||
|
||||
export const recentCompletions = $state<DailyCompletionCount[]>([]);
|
||||
|
||||
// Reactive getter. Svelte 5 does not allow exporting $derived at module
|
||||
// level ("derived_invalid_export"), so we expose a function instead.
|
||||
// Template consumers call todayCount() in reactive contexts and it
|
||||
// recomputes when recentCompletions changes.
|
||||
export function todayCount(): number {
|
||||
return recentCompletions.at(-1)?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function refresh(): Promise<void> {
|
||||
if (!hasTauriRuntime()) return;
|
||||
try {
|
||||
const series = await invoke<DailyCompletionCount[]>(
|
||||
"list_recent_completions_cmd",
|
||||
{ days: DAYS },
|
||||
);
|
||||
recentCompletions.splice(0, recentCompletions.length, ...series);
|
||||
} catch (err) {
|
||||
// Do not toast. This is a background read. Log only.
|
||||
console.error("completionStats.refresh failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const handler = () => {
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
window.addEventListener("kon:task-completed", handler);
|
||||
window.addEventListener("kon:step-completed", handler);
|
||||
window.addEventListener("kon:task-uncompleted", handler);
|
||||
window.addEventListener("kon:task-deleted", handler);
|
||||
window.addEventListener("focus", handler);
|
||||
|
||||
if (hasTauriRuntime()) {
|
||||
refresh().catch(() => {});
|
||||
}
|
||||
}
|
||||
238
src/lib/stores/focusTimer.svelte.ts
Normal file
238
src/lib/stores/focusTimer.svelte.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// 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() {
|
||||
// Broadcast cancellation so downstream listeners (Phase 6 nudge bus)
|
||||
// can reset their inactivity-while-timer state. Only fires when a
|
||||
// timer was actually active — dismissing the completion flash
|
||||
// after natural completion doesn't re-fire the event.
|
||||
const wasActive = startedAt !== null && !completionFired;
|
||||
startedAt = null;
|
||||
durationMs = 0;
|
||||
taskId = null;
|
||||
label = null;
|
||||
completionFlashUntil = 0;
|
||||
completionFired = false;
|
||||
writePersisted(null);
|
||||
stopTick();
|
||||
if (wasActive && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("kon:focus-timer-cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
260
src/lib/stores/implementationIntentions.svelte.ts
Normal file
260
src/lib/stores/implementationIntentions.svelte.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// Phase 7 implementation intentions. Rules are persisted in SQLite via
|
||||
// Tauri commands; this frontend runner binds them to the Phase 6 event
|
||||
// bus and executes their small local actions.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ImplementationRule,
|
||||
ImplementationRuleAction,
|
||||
ImplementationRuleDraft,
|
||||
} from "$lib/types/app";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
import { page, settings, tasks } from "$lib/stores/page.svelte.js";
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
|
||||
const TIME_RULE_POLL_MS = 30_000;
|
||||
const RULES_CHANGED_EVENT = "kon:implementation-rules-changed";
|
||||
|
||||
export const implementationRules = $state<ImplementationRule[]>([]);
|
||||
|
||||
let loaded = false;
|
||||
let started = false;
|
||||
let timePollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
const firingRules = new Set<string>();
|
||||
|
||||
function todayLocalKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function currentHHMM(): string {
|
||||
const d = new Date();
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function hasTimeAlreadyPassed(hhmm: string): boolean {
|
||||
return currentHHMM() >= hhmm;
|
||||
}
|
||||
|
||||
export function initialLastFiredKeyForTimeRule(hhmm: string): string | null {
|
||||
return hasTimeAlreadyPassed(hhmm) ? `${todayLocalKey()}@${hhmm}` : null;
|
||||
}
|
||||
|
||||
function replaceRule(next: ImplementationRule): void {
|
||||
const idx = implementationRules.findIndex((rule) => rule.id === next.id);
|
||||
if (idx >= 0) implementationRules[idx] = next;
|
||||
else implementationRules.unshift(next);
|
||||
}
|
||||
|
||||
function broadcastRulesChanged(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(RULES_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export async function loadImplementationRules(force = false): Promise<void> {
|
||||
if (!hasTauriRuntime()) {
|
||||
implementationRules.length = 0;
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
if (loaded && !force) return;
|
||||
const rows = await invoke<ImplementationRule[]>("list_implementation_rules");
|
||||
implementationRules.splice(0, implementationRules.length, ...rows);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
export async function createImplementationRule(
|
||||
draft: ImplementationRuleDraft,
|
||||
): Promise<ImplementationRule> {
|
||||
const row = await invoke<ImplementationRule>("create_implementation_rule", { request: draft });
|
||||
replaceRule(row);
|
||||
broadcastRulesChanged();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function setImplementationRuleEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
const row = await invoke<ImplementationRule>("set_implementation_rule_enabled", { id, enabled });
|
||||
replaceRule(row);
|
||||
broadcastRulesChanged();
|
||||
}
|
||||
|
||||
export async function deleteImplementationRule(id: string): Promise<void> {
|
||||
await invoke("delete_implementation_rule", { id });
|
||||
const idx = implementationRules.findIndex((rule) => rule.id === id);
|
||||
if (idx >= 0) implementationRules.splice(idx, 1);
|
||||
broadcastRulesChanged();
|
||||
}
|
||||
|
||||
async function markRuleFired(rule: ImplementationRule, firedKey: string): Promise<void> {
|
||||
const row = await invoke<ImplementationRule>("mark_implementation_rule_fired", {
|
||||
id: rule.id,
|
||||
firedKey,
|
||||
});
|
||||
replaceRule(row);
|
||||
}
|
||||
|
||||
function actionLabel(action: ImplementationRuleAction): string {
|
||||
if (action.kind === "speak_line") return `Speak "${action.text}"`;
|
||||
if (action.kind === "start_timer") return "Start a 5-minute timer";
|
||||
if (action.kind === "surface") {
|
||||
if (action.target === "task") return `Surface ${action.label || "task"}`;
|
||||
if (action.target === "inbox") return "Surface inbox";
|
||||
if (action.target === "today") return "Surface today";
|
||||
return "Surface tasks";
|
||||
}
|
||||
return "Action";
|
||||
}
|
||||
|
||||
export function ruleSummary(rule: ImplementationRule): string {
|
||||
const when = rule.triggerKind === "time_of_day"
|
||||
? `At ${rule.triggerValue}`
|
||||
: rule.triggerKind === "task_completed"
|
||||
? "After a task completes"
|
||||
: "After morning triage";
|
||||
return `${when} -> ${rule.actions.map(actionLabel).join(" + ")}`;
|
||||
}
|
||||
|
||||
function surfaceAction(action: Extract<ImplementationRuleAction, { kind: "surface" }>): void {
|
||||
if (action.target === "task" && action.taskId) {
|
||||
const task = tasks.find((candidate) => candidate.id === action.taskId);
|
||||
if (!task) {
|
||||
// The rule outlived its target. Don't fake a success toast —
|
||||
// surface the full list so the user can still act, and tell
|
||||
// them why the specific task isn't there. Cached label used
|
||||
// only for identification, not as a claim of surfacing.
|
||||
page.current = "tasks";
|
||||
page.taskSidebarOpen = true;
|
||||
const cachedLabel = action.label?.trim();
|
||||
toasts.warn(
|
||||
"Rule target missing",
|
||||
cachedLabel
|
||||
? `The task "${cachedLabel}" is gone — edit the rule to point at something else.`
|
||||
: "That rule's task is gone — edit the rule to point at something else.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
page.current = "tasks";
|
||||
page.taskSidebarOpen = true;
|
||||
toasts.info("Task ready", task.text);
|
||||
return;
|
||||
}
|
||||
|
||||
page.current = "tasks";
|
||||
page.taskSidebarOpen = true;
|
||||
|
||||
if (action.target === "inbox") {
|
||||
toasts.info("Inbox is here", "Your task list is open.");
|
||||
} else if (action.target === "today") {
|
||||
toasts.info("Today's list is here", "Your task list is open.");
|
||||
} else {
|
||||
toasts.info("Tasks are here", "Your list is open.");
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAction(action: ImplementationRuleAction): Promise<void> {
|
||||
if (action.kind === "surface") {
|
||||
surfaceAction(action);
|
||||
return;
|
||||
}
|
||||
if (action.kind === "start_timer") {
|
||||
window.dispatchEvent(new CustomEvent("kon:start-timer", {
|
||||
detail: {
|
||||
taskId: action.taskId ?? null,
|
||||
seconds: 300,
|
||||
label: action.label ?? "5-minute timer",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (action.kind === "speak_line") {
|
||||
await invoke("tts_speak", {
|
||||
text: action.text,
|
||||
rate: settings.ttsRate,
|
||||
voice: settings.ttsVoice ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fireRule(rule: ImplementationRule, firedKey?: string): Promise<void> {
|
||||
if (!rule.enabled) return;
|
||||
if (settings.nudgesMuted) return;
|
||||
if (firedKey && rule.lastFiredKey === firedKey) return;
|
||||
if (firingRules.has(rule.id)) return;
|
||||
|
||||
firingRules.add(rule.id);
|
||||
try {
|
||||
for (const action of rule.actions) {
|
||||
try {
|
||||
await executeAction(action);
|
||||
} catch {
|
||||
// Rule actions are assistance, not critical workflow. Keep going
|
||||
// so a missing TTS voice does not block surfacing a task.
|
||||
}
|
||||
}
|
||||
if (firedKey) {
|
||||
await markRuleFired(rule, firedKey);
|
||||
}
|
||||
} finally {
|
||||
firingRules.delete(rule.id);
|
||||
}
|
||||
}
|
||||
|
||||
function timeRuleFiredKey(rule: ImplementationRule): string {
|
||||
return `${todayLocalKey()}@${rule.triggerValue}`;
|
||||
}
|
||||
|
||||
async function checkTimeRules(): Promise<void> {
|
||||
if (!loaded) await loadImplementationRules();
|
||||
for (const rule of implementationRules) {
|
||||
if (rule.triggerKind !== "time_of_day") continue;
|
||||
if (!rule.enabled) continue;
|
||||
if (currentHHMM() < rule.triggerValue) continue;
|
||||
const firedKey = timeRuleFiredKey(rule);
|
||||
if (rule.lastFiredKey === firedKey) continue;
|
||||
await fireRule(rule, firedKey);
|
||||
}
|
||||
}
|
||||
|
||||
function onTaskCompleted(): void {
|
||||
for (const rule of implementationRules) {
|
||||
if (rule.triggerKind === "task_completed") void fireRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
function onMorningTriageFinished(): void {
|
||||
for (const rule of implementationRules) {
|
||||
if (rule.triggerKind === "morning_triage_finished") void fireRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
function onRulesChanged(): void {
|
||||
void checkTimeRules();
|
||||
}
|
||||
|
||||
export function startImplementationIntentions(): void {
|
||||
if (started) return;
|
||||
if (typeof window === "undefined") return;
|
||||
started = true;
|
||||
|
||||
void loadImplementationRules(true)
|
||||
.then(() => checkTimeRules())
|
||||
.catch(() => {});
|
||||
window.addEventListener("kon:task-completed", onTaskCompleted);
|
||||
window.addEventListener("kon:morning-triage-finished", onMorningTriageFinished);
|
||||
window.addEventListener(RULES_CHANGED_EVENT, onRulesChanged);
|
||||
timePollHandle = setInterval(() => {
|
||||
void checkTimeRules().catch(() => {});
|
||||
}, TIME_RULE_POLL_MS);
|
||||
}
|
||||
|
||||
export function stopImplementationIntentions(): void {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
window.removeEventListener("kon:task-completed", onTaskCompleted);
|
||||
window.removeEventListener("kon:morning-triage-finished", onMorningTriageFinished);
|
||||
window.removeEventListener(RULES_CHANGED_EVENT, onRulesChanged);
|
||||
if (timePollHandle !== null) {
|
||||
clearInterval(timePollHandle);
|
||||
timePollHandle = null;
|
||||
}
|
||||
}
|
||||
292
src/lib/stores/nudgeBus.svelte.ts
Normal file
292
src/lib/stores/nudgeBus.svelte.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// Phase 6 Margot nudge bus. Frontend-owned, subscribes to in-app
|
||||
// signals Corbie already produces, applies suppression, and fans out
|
||||
// to OS notification (via Rust's deliver_nudge) + optional TTS.
|
||||
//
|
||||
// Why frontend-only: OS-wide keyboard/window detection is fragile
|
||||
// across Wayland/macOS/Windows (no sanctioned global-keyboard API on
|
||||
// Wayland, accessibility permission on macOS, message-loop hook on
|
||||
// Windows). The app-local signals we already have — timer state,
|
||||
// task completion, micro-step creation, app focus/visibility — cover
|
||||
// the Phase 6 trigger set without the platform pain.
|
||||
//
|
||||
// Suppression rules:
|
||||
// - nudgesEnabled && !nudgesMuted
|
||||
// - document has focus → never nudge (user is already looking)
|
||||
// - rolling 1-hour cap of 3 nudges
|
||||
// - timer-specific: no nudge in first 60 s of a running timer
|
||||
//
|
||||
// Trigger set v1 (in-app only):
|
||||
// - inactivity_with_active_timer
|
||||
// - pending_morning_triage
|
||||
// - micro_step_idle
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
import { settings } from "$lib/stores/page.svelte.js";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
|
||||
const TIMER_WARMUP_MS = 60_000;
|
||||
const MICRO_STEP_IDLE_MS = 15 * 60_000;
|
||||
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
|
||||
const MORNING_TRIAGE_TRIGGER_HOUR = 10;
|
||||
|
||||
interface TimerStartPayload {
|
||||
taskId?: string;
|
||||
seconds?: number;
|
||||
}
|
||||
|
||||
interface MicroStepPayload {
|
||||
parentTaskId: string;
|
||||
}
|
||||
|
||||
let started = false;
|
||||
let permissionRequested = false;
|
||||
let permissionGranted = false;
|
||||
|
||||
// Rolling timestamps of recent nudge deliveries (ms since epoch).
|
||||
// Older-than-one-hour entries are pruned lazily on each check.
|
||||
const recentNudges: number[] = [];
|
||||
|
||||
// Per-trigger state.
|
||||
let timerRunning = false;
|
||||
let timerStartedAt = 0;
|
||||
let timerNudgeFiredThisSession = false;
|
||||
let blurredAt: number | null = null;
|
||||
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let triagePollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// parentTaskId → scheduled timeout handle for "idle since step created".
|
||||
// Cleared when a step or parent task gets marked done in the same session.
|
||||
const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// --- Permission ------------------------------------------------------
|
||||
|
||||
async function ensurePermission(): Promise<boolean> {
|
||||
if (!hasTauriRuntime()) return false;
|
||||
if (permissionRequested) return permissionGranted;
|
||||
permissionRequested = true;
|
||||
try {
|
||||
const plugin = await import("@tauri-apps/plugin-notification");
|
||||
const already = await plugin.isPermissionGranted();
|
||||
if (already) {
|
||||
permissionGranted = true;
|
||||
return true;
|
||||
}
|
||||
const result = await plugin.requestPermission();
|
||||
permissionGranted = result === "granted";
|
||||
return permissionGranted;
|
||||
} catch {
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suppression -----------------------------------------------------
|
||||
|
||||
function pruneRecentNudges(now: number): void {
|
||||
while (recentNudges.length > 0 && now - recentNudges[0] > HOUR_MS) {
|
||||
recentNudges.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function canNudgeNow(now: number): boolean {
|
||||
if (!settings.nudgesEnabled) return false;
|
||||
if (settings.nudgesMuted) return false;
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return false;
|
||||
pruneRecentNudges(now);
|
||||
if (recentNudges.length >= 3) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Delivery --------------------------------------------------------
|
||||
|
||||
async function deliver(title: string, body: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!canNudgeNow(now)) return;
|
||||
const ok = await ensurePermission();
|
||||
if (!ok) return;
|
||||
recentNudges.push(now);
|
||||
try {
|
||||
await invoke("deliver_nudge", { input: { title, body } });
|
||||
} catch {
|
||||
// Nudges are fire-and-forget — surfacing an error to the user
|
||||
// would defeat the "anticipatory, not push-notification" framing.
|
||||
}
|
||||
if (settings.nudgesSpeakAloud) {
|
||||
try {
|
||||
await invoke("tts_speak", {
|
||||
text: body || title,
|
||||
rate: settings.ttsRate,
|
||||
voice: settings.ttsVoice ?? null,
|
||||
});
|
||||
} catch {
|
||||
// Same reasoning — never surface TTS failures through a nudge.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trigger: inactivity with active timer ---------------------------
|
||||
|
||||
function onTimerStart(event: Event) {
|
||||
const detail = (event as CustomEvent<TimerStartPayload>).detail;
|
||||
timerRunning = true;
|
||||
timerStartedAt = Date.now();
|
||||
timerNudgeFiredThisSession = false;
|
||||
// If the user switched away before starting the timer, the next
|
||||
// blur check (below) will pick it up.
|
||||
void detail;
|
||||
}
|
||||
|
||||
function resetTimerState() {
|
||||
timerRunning = false;
|
||||
timerStartedAt = 0;
|
||||
timerNudgeFiredThisSession = false;
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
blurredAt = null;
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
blurredAt = Date.now();
|
||||
}
|
||||
|
||||
function checkInactivityWithActiveTimer(now: number) {
|
||||
if (!timerRunning || timerNudgeFiredThisSession) return;
|
||||
if (now - timerStartedAt < TIMER_WARMUP_MS) return;
|
||||
if (blurredAt === null) return;
|
||||
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
|
||||
timerNudgeFiredThisSession = true;
|
||||
void deliver(
|
||||
"Still on that timer?",
|
||||
"It's been ticking while you've been away. Pick up where you left off, or stop it.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Trigger: pending morning triage ---------------------------------
|
||||
|
||||
function todayLocalKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
let triageNudgeFiredOnKey: string | null = null;
|
||||
|
||||
async function checkPendingMorningTriage() {
|
||||
if (!settings.ritualsMorning) return;
|
||||
if (!hasTauriRuntime()) return;
|
||||
const now = new Date();
|
||||
if (now.getHours() < MORNING_TRIAGE_TRIGGER_HOUR) return;
|
||||
const today = todayLocalKey();
|
||||
if (triageNudgeFiredOnKey === today) return;
|
||||
let lastShown: string | null = null;
|
||||
try {
|
||||
lastShown = await invoke<string | null>("get_last_morning_triage");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (lastShown === today) return;
|
||||
triageNudgeFiredOnKey = today;
|
||||
void deliver(
|
||||
"A few things waiting",
|
||||
"When you're ready, Corbie has your morning list. No rush.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Trigger: micro-step idle ----------------------------------------
|
||||
|
||||
function onMicroStepGenerated(event: Event) {
|
||||
const detail = (event as CustomEvent<MicroStepPayload>).detail;
|
||||
if (!detail?.parentTaskId) return;
|
||||
const parentTaskId = detail.parentTaskId;
|
||||
clearMicroStepTimer(parentTaskId);
|
||||
const handle = setTimeout(() => {
|
||||
microStepTimers.delete(parentTaskId);
|
||||
void deliver(
|
||||
"Still with that one?",
|
||||
"Your breakdown is here when you want to pick the first step.",
|
||||
);
|
||||
}, MICRO_STEP_IDLE_MS);
|
||||
microStepTimers.set(parentTaskId, handle);
|
||||
}
|
||||
|
||||
function clearMicroStepTimer(parentTaskId: string) {
|
||||
const handle = microStepTimers.get(parentTaskId);
|
||||
if (handle !== undefined) {
|
||||
clearTimeout(handle);
|
||||
microStepTimers.delete(parentTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
function onStepOrTaskCompleted(event: Event) {
|
||||
const detail = (event as CustomEvent<{ id?: string; parentTaskId?: string }>).detail;
|
||||
// A completed subtask clears its parent's idle timer; a completed
|
||||
// parent task clears its own idle timer (the step decomposition
|
||||
// attached to that task is no longer waiting on anyone).
|
||||
if (detail?.parentTaskId) clearMicroStepTimer(detail.parentTaskId);
|
||||
if (detail?.id) clearMicroStepTimer(detail.id);
|
||||
}
|
||||
|
||||
// --- Lifecycle -------------------------------------------------------
|
||||
|
||||
export function startNudgeBus(): void {
|
||||
if (started) return;
|
||||
if (typeof window === "undefined") return;
|
||||
started = true;
|
||||
|
||||
window.addEventListener("kon:start-timer", onTimerStart);
|
||||
window.addEventListener("kon:focus-timer-complete", resetTimerState);
|
||||
window.addEventListener("kon:focus-timer-cancelled", resetTimerState);
|
||||
window.addEventListener("kon:microstep-generated", onMicroStepGenerated);
|
||||
window.addEventListener("kon:step-completed", onStepOrTaskCompleted);
|
||||
window.addEventListener("kon:task-completed", onStepOrTaskCompleted);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
|
||||
// Inactivity check runs while a timer is live. Low frequency is fine
|
||||
// — the nudge itself only fires once the 90 s threshold is crossed.
|
||||
blurCheckHandle = setInterval(() => {
|
||||
checkInactivityWithActiveTimer(Date.now());
|
||||
}, 10_000);
|
||||
|
||||
// Morning triage check runs on an interval + one immediate probe on
|
||||
// start. The Rust `get_last_morning_triage` call is cheap (one
|
||||
// SQLite read), so polling every 5 min costs nothing and means we
|
||||
// catch the 10:00 threshold without a scheduler.
|
||||
void checkPendingMorningTriage();
|
||||
triagePollHandle = setInterval(() => {
|
||||
void checkPendingMorningTriage();
|
||||
}, MORNING_TRIAGE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopNudgeBus(): void {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
|
||||
window.removeEventListener("kon:start-timer", onTimerStart);
|
||||
window.removeEventListener("kon:focus-timer-complete", resetTimerState);
|
||||
window.removeEventListener("kon:focus-timer-cancelled", resetTimerState);
|
||||
window.removeEventListener("kon:microstep-generated", onMicroStepGenerated);
|
||||
window.removeEventListener("kon:step-completed", onStepOrTaskCompleted);
|
||||
window.removeEventListener("kon:task-completed", onStepOrTaskCompleted);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
|
||||
if (blurCheckHandle !== null) {
|
||||
clearInterval(blurCheckHandle);
|
||||
blurCheckHandle = null;
|
||||
}
|
||||
if (triagePollHandle !== null) {
|
||||
clearInterval(triagePollHandle);
|
||||
triagePollHandle = null;
|
||||
}
|
||||
for (const handle of microStepTimers.values()) clearTimeout(handle);
|
||||
microStepTimers.clear();
|
||||
|
||||
resetTimerState();
|
||||
blurredAt = null;
|
||||
triageNudgeFiredOnKey = null;
|
||||
permissionRequested = false;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
EnergyLevel,
|
||||
PageState,
|
||||
Profile,
|
||||
Segment,
|
||||
@@ -63,11 +64,25 @@ const defaults: SettingsState = {
|
||||
llmModelId: null,
|
||||
llmPromptPreset: "default",
|
||||
aiGpuConcurrency: "parallel",
|
||||
prewarmModelOnStartup: false,
|
||||
saveAudio: false,
|
||||
outputFolder: "",
|
||||
globalHotkey: "Ctrl+Shift+R",
|
||||
sidebarCollapsed: false,
|
||||
microphoneDevice: "",
|
||||
currentEnergy: null,
|
||||
matchMyEnergy: false,
|
||||
ttsVoice: null,
|
||||
ttsRate: 1.0,
|
||||
ritualsMorning: false,
|
||||
ritualsMorningTime: "08:00",
|
||||
ritualsEvening: false,
|
||||
launchAtLogin: false,
|
||||
ritualsPromptSeen: false,
|
||||
nudgesEnabled: false,
|
||||
nudgesMuted: false,
|
||||
nudgesSpeakAloud: false,
|
||||
showMomentumSparkline: true,
|
||||
};
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
@@ -125,10 +140,12 @@ export function saveProfiles() {
|
||||
|
||||
export const history = $state<TranscriptEntry[]>([]);
|
||||
|
||||
function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
|
||||
export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
|
||||
const text = row.text ?? "";
|
||||
const rawTags = row.manualTags ?? "";
|
||||
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
|
||||
const rawLlmTags = row.llmTags ?? "";
|
||||
const llmTags = rawLlmTags ? rawLlmTags.split(",").filter(Boolean) : [];
|
||||
const rawSegments = row.segmentsJson ?? "";
|
||||
let segments: Segment[] = [];
|
||||
if (rawSegments) {
|
||||
@@ -158,6 +175,7 @@ function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
|
||||
template: row.template ?? "",
|
||||
language: row.language ?? "",
|
||||
segments,
|
||||
llmTags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +199,7 @@ function normaliseTranscriptEntry(entry: TranscriptWriteEntry): TranscriptEntry
|
||||
template: entry.template ?? "",
|
||||
language: entry.language ?? "",
|
||||
segments: Array.isArray(entry.segments) ? entry.segments : [],
|
||||
llmTags: Array.isArray(entry.llmTags) ? entry.llmTags : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,8 +223,6 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
|
||||
loadHistory().catch(() => {});
|
||||
}
|
||||
|
||||
export function saveHistory() {}
|
||||
|
||||
export async function addToHistory(entry: TranscriptWriteEntry) {
|
||||
const normalised = normaliseTranscriptEntry(entry);
|
||||
history.unshift(normalised);
|
||||
@@ -277,6 +294,11 @@ export async function saveTranscriptMeta(id: string, patch: TranscriptMetaPatch)
|
||||
template: patch.template,
|
||||
language: patch.language,
|
||||
segmentsJson: patch.segments === undefined ? undefined : JSON.stringify(patch.segments),
|
||||
llmTags: patch.llmTags == null
|
||||
? undefined
|
||||
: (Array.isArray(patch.llmTags)
|
||||
? patch.llmTags.join(",")
|
||||
: String(patch.llmTags)),
|
||||
};
|
||||
const row = await invoke<TranscriptDto>("update_transcript_meta_cmd", {
|
||||
id: String(id),
|
||||
@@ -299,6 +321,12 @@ export function deleteFromHistory(index: number) {
|
||||
.catch((err) => console.warn("deleteFromHistory: SQLite delete failed", err));
|
||||
}
|
||||
|
||||
export function deleteFromHistoryById(id: string) {
|
||||
const idx = history.findIndex((entry) => String(entry.id) === String(id));
|
||||
if (idx === -1) return;
|
||||
deleteFromHistory(idx);
|
||||
}
|
||||
|
||||
export const tasks = $state<TaskEntry[]>([]);
|
||||
|
||||
function mapTaskRow(row: TaskDto): TaskEntry {
|
||||
@@ -314,6 +342,7 @@ function mapTaskRow(row: TaskDto): TaskEntry {
|
||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||
sourceTranscriptId: row.sourceTranscriptId ?? null,
|
||||
parentTaskId: row.parentTaskId ?? null,
|
||||
energy: row.energy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,6 +381,7 @@ export async function addTask(task: TaskDraft) {
|
||||
sourceTranscriptId: task.sourceTranscriptId || null,
|
||||
listId: task.listId ?? null,
|
||||
effort: task.effort ?? null,
|
||||
energy: task.energy ?? null,
|
||||
},
|
||||
});
|
||||
tasks.unshift(mapTaskRow(row));
|
||||
@@ -396,6 +426,29 @@ export async function updateTask(id: string, updates: TaskUpdate) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: explicit tri-state energy setter. Lives outside `updateTask`
|
||||
* because the backend uses a dedicated command for clearing — see
|
||||
* `set_task_energy_cmd` in src-tauri/src/commands/tasks.rs. Pass `null`
|
||||
* to clear the tag entirely; pass an `EnergyLevel` string to set it.
|
||||
*/
|
||||
export async function setTaskEnergy(id: string, energy: EnergyLevel | null) {
|
||||
if (!hasTauriRuntime()) {
|
||||
applyLocalTaskUpdate(id, { energy });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const row = await invoke<TaskDto>("set_task_energy_cmd", { id, energy });
|
||||
const idx = tasks.findIndex((task) => task.id === id);
|
||||
if (idx >= 0) {
|
||||
tasks[idx] = mapTaskRow(row);
|
||||
broadcastTasks();
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error("Couldn't change energy", errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string) {
|
||||
if (!hasTauriRuntime()) return;
|
||||
|
||||
@@ -405,6 +458,9 @@ export async function deleteTask(id: string) {
|
||||
if (idx >= 0) {
|
||||
tasks.splice(idx, 1);
|
||||
broadcastTasks();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("kon:task-deleted", { detail: { id } }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error("Couldn't delete task", errorMessage(err));
|
||||
@@ -417,6 +473,12 @@ export async function completeTask(id: string) {
|
||||
try {
|
||||
await invoke("complete_task_cmd", { id });
|
||||
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
|
||||
// Phase 6 nudge-bus signal. Fire-and-forget; the bus subscribes
|
||||
// to this to clear micro-step-idle timers for the completed task
|
||||
// and, later, to drive Phase 7 "after a task completes" rules.
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("kon:task-completed", { detail: { id } }));
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error("Couldn't complete task", errorMessage(err));
|
||||
}
|
||||
@@ -428,6 +490,9 @@ export async function uncompleteTask(id: string) {
|
||||
try {
|
||||
await invoke("uncomplete_task_cmd", { id });
|
||||
applyLocalTaskUpdate(id, { done: false, doneAt: null });
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("kon:task-uncompleted", { detail: { id } }));
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error("Couldn't uncomplete task", errorMessage(err));
|
||||
}
|
||||
|
||||
10
src/lib/stores/speaker.svelte.ts
Normal file
10
src/lib/stores/speaker.svelte.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Phase 4 Read Page Aloud: tracks which SpeakerButton instance is
|
||||
// currently driving TTS. Only one speaker is active at a time — when
|
||||
// a second button starts speech, the first reverts its icon via the
|
||||
// `$derived` subscription in SpeakerButton.
|
||||
|
||||
export const activeSpeaker = $state<{ id: string | null }>({ id: null });
|
||||
|
||||
export function setActiveSpeaker(id: string | null): void {
|
||||
activeSpeaker.id = id;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user