19 Commits

Author SHA1 Message Date
8c9c9390d8 fix(storage): PRAGMA foreign_keys=ON; atomic transaction in complete_subtask_and_check_parent; uncomplete_task moved to storage layer
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
2026-04-19 11:00:37 +01:00
f54d55d110 fix(tasks): add explicit parentTaskId field to addTask shape — makes !t.parentTaskId filter intent clear 2026-04-19 10:55:30 +01:00
674fc2c4b8 feat(ui): add MicroSteps expand/collapse to WipTaskList; exclude subtasks from WIP count 2026-04-19 10:51:11 +01:00
442fa6656e feat(ui): add MicroSteps component with decompose button, step checklist, and Just Start timer 2026-04-19 10:49:01 +01:00
0bce8e6ec4 feat(tasks): dual-write tasks to SQLite alongside localStorage; boot-load from SQLite on startup 2026-04-19 10:46:07 +01:00
8d3d302b17 feat(tasks): wire decompose_and_store, list_subtasks_cmd, complete_subtask_cmd; add llm_engine to AppState 2026-04-19 10:44:08 +01:00
8640b255e9 feat(llm): add kon-llm stub crate with LlmEngine interface — Phase 3 will wire real model 2026-04-19 10:42:24 +01:00
b1b3c689d6 feat(tasks): add Tauri CRUD commands — create_task_cmd, list_tasks_cmd, complete_task_cmd, delete_task_cmd, uncomplete_task_cmd 2026-04-19 10:39:56 +01:00
6f264d8bec refactor(storage): extract task_row_from helper — consistent with transcript_row_from pattern 2026-04-19 10:37:55 +01:00
35efed53e5 feat(storage): extend task layer for subtasks — get_task_by_id, insert_subtask, list_subtasks, complete_subtask_and_check_parent 2026-04-19 10:34:59 +01:00
fa20cb313a feat(storage): add parent_task_id migration for micro-stepping 2026-04-19 10:21:43 +01:00
b479a368e7 fix(clipboard): use navigator.clipboard.writeText as primary, arboard as fallback — fixes silent failure on Linux/XWayland 2026-04-19 10:21:10 +01:00
d959a82a4b fix(ui): remove cursor-following recording dot — distracting and redundant with status bar indicator 2026-04-19 10:16:53 +01:00
cc0dc1b57c docs: session handover 2026/04/18 2026-04-18 10:36:56 +01:00
82c2631f28 docs: add dev-setup.md — authoritative build deps, launch commands, and gotcha reference 2026-04-18 10:34:35 +01:00
ac46949b01 feat(transcription): enable Vulkan GPU acceleration for Whisper inference 2026-04-18 10:32:09 +01:00
e436a69839 fix(preferences): use Object.assign mutation to prevent infinite effect loops
Replacing the preferences object (spread reassignment) caused all consumers'
prefs references to go stale — the loop guard read the old value forever.
Svelte 5 shared state pattern requires a stable const object with property
mutations, not reassignment. Also removes duplicate theme sync $effect from
SettingsPage (already handled in +layout.svelte).
2026-04-18 10:18:29 +01:00
61c96d7805 fix(startup): use tauri::async_runtime::spawn for pre-warm — tokio::spawn panics before runtime is live in setup() 2026-04-18 10:10:16 +01:00
0004433f2d fix: apply initial DOM state from preferences store on load (no-flash in browser dev mode) 2026-04-18 10:02:54 +01:00
24 changed files with 889 additions and 80 deletions

71
HANDOVER.md Normal file
View File

@@ -0,0 +1,71 @@
---
name: handover-2026-04-18
type: reference
tags: [handover, session, kon]
description: Session handover — 2026/04/18 dogfooding sprint
---
# Kon Handover — 2026/04/18
## Current state
Phase 1 brand migration and Phase 2 polish are both **complete and committed**. Today was the first dogfood attempt — Vulkan GPU build is in progress but not yet confirmed working. Three bugs were caught and fixed during the first launch attempt.
## What's working
- **18/18 automated validation checks pass** (Playwright, `python3 /tmp/kon_validation.py`)
- **Pre-warm fixed** — `tauri::async_runtime::spawn` instead of `tokio::spawn`; model loads in background before first dictation
- **Preferences infinite loop fixed** — `Object.assign` mutation instead of object reassignment; Svelte 5 module state now stable
- **DOM hydration fixed** — `applyToDOM` called on store init so `data-theme` is always set, even without Tauri webview injection
- **Vulkan feature flag committed** — `whisper-vulkan` in `crates/transcription/Cargo.toml`
- **`docs/dev-setup.md`** — authoritative dependency and launch reference
## What's left
### Immediate — Vulkan GPU build
Vulkan build was not yet confirmed. Three system packages needed before it will compile:
```bash
sudo dnf install vulkan-headers vulkan-loader-devel glslc
```
Then launch:
```bash
cd /home/jake/Documents/CORBEL-Projects/kon
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
```
Confirm GPU active in startup logs:
```
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070
```
### Manual validation (requires running app)
Three items from the validation checklist that need real Tauri runtime:
- [ ] Persistence test — set non-default zone/font, close, relaunch, verify zero flash
- [ ] Cross-window preferences — open float/viewer windows, check they hydrate correctly
- [ ] 90-second onboarding — fresh-model launch, first dictation under 90s
### Pre-release (before any build beyond Jake's machine)
- [ ] Updater signing key — `tauri signer generate`, public key → `tauri.conf.json`, private key → CI secrets
- [ ] ggml dedup — plan at `docs/superpowers/plans/2026-04-18-kon-ggml-dedup.md`, Option A (system-ggml shared lib), execute at Phase 3
## Gotchas discovered today
| Issue | Fix |
|---|---|
| `libclang` not on PATH | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` |
| `tokio::spawn` panics in Tauri `setup()` | Use `tauri::async_runtime::spawn` — Tokio runtime isn't live yet during setup |
| Svelte 5 `$effect` infinite loop on `updatePreferences` | Module-level `$state` must be mutated (`Object.assign`), never reassigned — stale references break loop guards |
| Duplicate theme sync `$effect` in both `+layout.svelte` and `SettingsPage.svelte` | Removed from SettingsPage — layout handles it |
| Vulkan build needs dev headers + shader compiler | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` |
## Resume prompt
```
Picking up Kon dogfooding from the 2026/04/18 session.
HANDOVER is at HANDOVER.md in the project root.
First job: confirm Vulkan GPU build compiles and check startup logs for RTX 4070.
Then run the three manual validation items from the handover.
```

6
crates/llm/Cargo.toml Normal file
View File

@@ -0,0 +1,6 @@
[package]
name = "kon-llm"
version = "0.1.0"
edition = "2021"
[dependencies]

73
crates/llm/src/lib.rs Normal file
View File

@@ -0,0 +1,73 @@
use std::sync::{Arc, Mutex};
struct LlmState {
loaded: bool,
}
/// Shared handle to the LLM engine. Cheap to clone (Arc).
/// Phase 3 will replace the stub body with a real llama-cpp-2 model.
#[derive(Clone)]
pub struct LlmEngine {
state: Arc<Mutex<LlmState>>,
}
impl LlmEngine {
pub fn new() -> Self {
Self {
state: Arc::new(Mutex::new(LlmState { loaded: false })),
}
}
pub fn is_loaded(&self) -> bool {
self.state.lock().unwrap().loaded
}
/// Break a task description into 3-7 physical micro-steps.
/// Returns Err if no model is loaded — the caller surfaces this to the UI.
pub fn decompose_task(&self, _task_text: &str) -> Result<Vec<String>, String> {
if !self.is_loaded() {
return Err(
"Download an AI model in Settings to break down tasks.".to_string(),
);
}
// Phase 3: call llama-cpp-2 with GBNF-constrained prompt here.
Err("LLM not yet wired.".to_string())
}
}
impl Default for LlmEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decompose_returns_error_when_not_loaded() {
let engine = LlmEngine::new();
assert!(!engine.is_loaded());
let result = engine.decompose_task("Write a blog post");
assert!(result.is_err());
assert!(
result.unwrap_err().contains("Download an AI model"),
"error message should tell user to download a model"
);
}
#[test]
fn default_creates_unloaded_engine() {
let engine = LlmEngine::default();
assert!(!engine.is_loaded());
}
#[test]
fn engine_is_clone_and_shares_state() {
let engine = LlmEngine::new();
let clone = engine.clone();
// Both point to the same Arc — neither is loaded
assert!(!clone.is_loaded());
}
}

View File

@@ -21,6 +21,11 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
.await
.map_err(|e| KonError::StorageError(format!("Database connect failed: {e}")))?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.map_err(|e| KonError::StorageError(format!("foreign_keys pragma failed: {e}")))?;
run_migrations(&pool).await?;
Ok(pool)
@@ -286,27 +291,106 @@ pub async fn insert_task(
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows =
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC")
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, parent_task_id FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC")
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
})
.map(task_row_from)
.collect())
}
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
let row = sqlx::query(
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
source_transcript_id, parent_task_id FROM tasks WHERE id = ?"
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get task failed: {e}")))?;
Ok(row.map(task_row_from))
}
pub async fn insert_subtask(
pool: &SqlitePool,
id: &str,
text: &str,
parent_task_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)"
)
.bind(id)
.bind(text)
.bind(parent_task_id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?;
Ok(())
}
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
source_transcript_id, parent_task_id \
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC"
)
.bind(parent_id)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List subtasks failed: {e}")))?;
Ok(rows.into_iter().map(task_row_from).collect())
}
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
/// Runs in a transaction so concurrent completions see consistent sibling counts.
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
let mut tx = pool.begin().await
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(subtask_id)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Complete subtask failed: {e}")))?;
let parent_id: Option<String> = sqlx::query_scalar(
"SELECT parent_task_id FROM tasks WHERE id = ?"
)
.bind(subtask_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
if let Some(pid) = parent_id {
let pending: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0"
)
.bind(&pid)
.fetch_one(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Count pending subtasks failed: {e}")))?;
if pending == 0 {
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(&pid)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?;
}
}
tx.commit().await
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
Ok(())
}
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(id)
@@ -316,6 +400,15 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(())
}
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Uncomplete task failed: {e}")))?;
Ok(())
}
pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
@@ -379,6 +472,7 @@ pub struct TaskRow {
pub done_at: Option<String>,
pub created_at: String,
pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
}
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
@@ -402,6 +496,21 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
}
}
fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
parent_task_id: r.get("parent_task_id"),
}
}
// --- Error Logging ---
/// Log a structured error to the `error_log` table.
@@ -552,6 +661,33 @@ mod tests {
assert!(tasks.is_empty());
}
#[tokio::test]
async fn subtask_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "p1", "Write report", "inbox", None).await.unwrap();
insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap();
insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap();
// list_tasks must exclude subtasks
let top_level = list_tasks(&pool).await.unwrap();
assert_eq!(top_level.len(), 1);
assert_eq!(top_level[0].id, "p1");
// list_subtasks returns both children
let subs = list_subtasks(&pool, "p1").await.unwrap();
assert_eq!(subs.len(), 2);
// completing s1 should NOT auto-complete parent (s2 still pending)
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(!parent.done, "parent should not be done yet");
// completing s2 SHOULD auto-complete parent
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(parent.done, "parent should auto-complete when all children done");
}
#[tokio::test]
async fn settings_crud_roundtrip() {
let pool = test_pool().await;

View File

@@ -3,10 +3,11 @@ pub mod file_storage;
pub mod migrations;
pub use database::{
add_dictionary_entry, complete_task, count_transcripts, delete_dictionary_entry,
delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
insert_transcript, list_dictionary, list_recent_errors, list_tasks, list_transcripts,
list_transcripts_paged, log_error, search_transcripts, set_setting, update_transcript,
DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow,
add_dictionary_entry, complete_subtask_and_check_parent, complete_task, count_transcripts,
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry, ErrorLogRow,
InsertTranscriptParams, TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};

View File

@@ -108,6 +108,10 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
CREATE INDEX IF NOT EXISTS idx_dictionary_term ON dictionary(term)
"#),
(3, "micro-stepping: parent_task_id on tasks", r#"
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)
"#),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -207,7 +211,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
assert_eq!(count, 3);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -229,6 +233,40 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
assert_eq!(count, 3);
}
#[tokio::test]
async fn test_parent_task_id_cascade_delete() {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
run_migrations(&pool).await.unwrap();
// Insert parent task
sqlx::query("INSERT INTO tasks (id, text) VALUES ('parent-1', 'Parent task')")
.execute(&pool)
.await
.unwrap();
// Insert child task with parent_task_id
sqlx::query("INSERT INTO tasks (id, text, parent_task_id) VALUES ('child-1', 'Child task', 'parent-1')")
.execute(&pool)
.await
.unwrap();
// Delete parent — child should cascade
sqlx::query("DELETE FROM tasks WHERE id = 'parent-1'")
.execute(&pool)
.await
.unwrap();
let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 0, "cascade delete should remove child when parent is deleted");
}
}

View File

@@ -8,7 +8,7 @@ description = "Speech-to-text engine wrappers, model management, and inference c
kon-core = { path = "../core" }
# Unified STT engine (Parakeet via ONNX, Whisper via whisper.cpp)
transcribe-rs = { version = "0.3", features = ["onnx", "whisper-cpp"] }
transcribe-rs = { version = "0.3", features = ["onnx", "whisper-cpp", "whisper-vulkan"] }
# Async runtime for spawn_blocking
tokio = { version = "1", features = ["rt", "sync"] }

129
docs/dev-setup.md Normal file
View File

@@ -0,0 +1,129 @@
---
name: dev-setup
type: reference
tags: [setup, dependencies, build, linux, fedora]
description: Authoritative build dependencies and launch instructions for Kon on Fedora Linux
---
# Kon — Developer Setup
Last updated: 2026/04/18. Primary dev target: Fedora 43, x86_64, KDE Wayland, NVIDIA RTX 4070.
---
## System dependencies
### Required (CPU build)
```bash
sudo dnf install cmake clang-devel
```
| Package | Why |
|---|---|
| `cmake` | whisper-rs-sys build system |
| `clang-devel` | bindgen header generation for whisper-rs-sys |
**Fedora-specific:** `libclang.so` lives in `/usr/lib64/llvm21/lib64/`, not on the standard search path. Set permanently:
```bash
set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64
```
Or prefix every build command:
```bash
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
```
### Required (Vulkan GPU build)
```bash
sudo dnf install vulkan-headers vulkan-loader-devel glslc
```
| Package | Why |
|---|---|
| `vulkan-headers` | `vulkan.h` needed by ggml-vulkan CMake |
| `vulkan-loader-devel` | `libvulkan.so` link target for CMake |
| `glslc` | Compiles GLSL compute shaders to SPIR-V at build time |
The NVIDIA Vulkan ICD (`nvidia_icd.json`) is included in the standard NVIDIA driver package — no extra install needed if the driver is already installed.
---
## Node / Rust
```bash
npm install # frontend deps — run once after clone
```
Rust toolchain managed by `rustup`. No extra steps needed beyond what Tauri requires.
---
## Launch commands
### CPU build (default)
```bash
cd /home/jake/Documents/CORBEL-Projects/kon
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
```
Once `set -Ux LIBCLANG_PATH` is in fish config, this becomes:
```bash
npm run tauri dev
```
### Vulkan GPU build
Same command — the `whisper-vulkan` feature flag is already set in `crates/transcription/Cargo.toml`. First build compiles Vulkan compute shaders and takes longer than usual.
Confirm GPU is active in startup logs:
```
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 ← GPU active
```
vs CPU fallback:
```
whisper_backend_init_gpu: device 0: CPU (type: 0) ← no GPU
```
---
## Startup log reference
Normal startup sequence:
```
[startup] Wayland workaround: GDK_BACKEND=x11
[startup] DB init: ~4ms
[startup] Preferences load: ~200µs
[startup] Whisper model pre-warmed successfully
```
The Wayland workarounds are injected automatically by `ensure_x11_on_wayland()` in `src-tauri/src/lib.rs` — no manual env-var prefix needed.
---
## Known build gotchas
| Issue | Cause | Fix |
|---|---|---|
| `Unable to find libclang` | Fedora puts clang libs in versioned path | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` |
| `Could NOT find Vulkan (missing: glslc)` | Shader compiler not installed | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` |
| `there is no reactor running` | `tokio::spawn` called before runtime starts in `setup()` | Use `tauri::async_runtime::spawn` instead |
| `effect_update_depth_exceeded` | Svelte 5 `$state` object reassigned instead of mutated | Use `Object.assign(state, updates)` — never spread-replace module-level state |
---
## GPU notes
- **Vulkan** is the GPU backend used here. CUDA is not required.
- `crates/transcription/Cargo.toml` feature: `whisper-vulkan``whisper-rs/vulkan``ggml-vulkan`
- CPU and GPU builds are otherwise identical — same binary, same model files.
- Expected speedup on RTX 4070: ~1015× over CPU for `whisper-base.en`.

View File

@@ -21,6 +21,7 @@ kon-ai-formatting = { path = "../crates/ai-formatting" }
kon-storage = { path = "../crates/storage" }
kon-cloud-providers = { path = "../crates/cloud-providers" }
kon-hotkey = { path = "../crates/hotkey" }
kon-llm = { path = "../crates/llm" }
# Tauri
tauri = { version = "2", features = ["tray-icon"] }
@@ -41,6 +42,7 @@ arboard = "3.6.1"
# stores it). Must be unconditional, not Linux-only — naming a type from
# a transitive dep requires the dep be listed here too.
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
uuid = { version = "1", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"

View File

@@ -6,6 +6,7 @@ pub mod hotkey;
pub mod live;
pub mod models;
pub mod transcription;
pub mod tasks;
pub mod transcripts;
pub mod update;
pub mod windows;

View File

@@ -175,8 +175,8 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
return;
}
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
tauri::async_runtime::spawn(async move {
let result = tauri::async_runtime::spawn_blocking(move || {
load_model_from_disk(&model_id).map(|model| {
whisper_engine.load(model, model_id);
})

View File

@@ -0,0 +1,168 @@
// Tauri commands wrapping kon_storage task CRUD.
// Pattern mirrors transcripts.rs — TaskDto is the camelCase frontend shape,
// storage functions are aliased with db_ prefix to avoid name collisions.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
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,
TaskRow,
};
use crate::AppState;
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskDto {
pub id: String,
pub text: String,
pub bucket: String,
pub list_id: Option<String>,
pub effort: Option<String>,
pub done: bool,
pub done_at: Option<String>,
pub created_at: String,
pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
}
impl From<TaskRow> for TaskDto {
fn from(r: TaskRow) -> Self {
Self {
id: r.id,
text: r.text,
bucket: r.bucket,
list_id: r.list_id,
effort: r.effort,
done: r.done,
done_at: r.done_at,
created_at: r.created_at,
source_transcript_id: r.source_transcript_id,
parent_task_id: r.parent_task_id,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTaskRequest {
pub id: String,
pub text: String,
pub bucket: String,
pub source_transcript_id: Option<String>,
}
#[tauri::command]
pub async fn create_task_cmd(
state: tauri::State<'_, AppState>,
request: CreateTaskRequest,
) -> Result<(), String> {
db_insert_task(
&state.db,
&request.id,
&request.text,
&request.bucket,
request.source_transcript_id.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_tasks_cmd(
state: tauri::State<'_, AppState>,
) -> Result<Vec<TaskDto>, String> {
db_list_tasks(&state.db)
.await
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn complete_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
db_complete_task(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
db_delete_task(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn uncomplete_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
db_uncomplete_task(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn decompose_and_store(
state: tauri::State<'_, AppState>,
parent_task_id: 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"))?;
let steps = state.llm_engine.decompose_task(&parent.text)?;
let mut created = Vec::new();
for text in steps {
let id = Uuid::new_v4().to_string();
db_insert_subtask(&state.db, &id, &text, &parent_task_id)
.await
.map_err(|e| e.to_string())?;
if let Some(row) = db_get_task(&state.db, &id).await.map_err(|e| e.to_string())? {
created.push(TaskDto::from(row));
}
}
Ok(created)
}
#[tauri::command]
pub async fn list_subtasks_cmd(
state: tauri::State<'_, AppState>,
parent_task_id: String,
) -> Result<Vec<TaskDto>, String> {
db_list_subtasks(&state.db, &parent_task_id)
.await
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn complete_subtask_cmd(
state: tauri::State<'_, AppState>,
subtask_id: String,
) -> Result<(), String> {
db_complete_subtask(&state.db, &subtask_id)
.await
.map_err(|e| e.to_string())
}

View File

@@ -8,6 +8,7 @@ use sqlx::SqlitePool;
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_transcription::LocalEngine;
@@ -16,6 +17,7 @@ pub struct AppState {
pub whisper_engine: Arc<LocalEngine>,
pub parakeet_engine: Arc<LocalEngine>,
pub db: SqlitePool,
pub llm_engine: Arc<LlmEngine>,
}
/// Holds the preferences init script for injection into secondary windows.
@@ -214,6 +216,7 @@ pub fn run() {
EngineName::new("parakeet"),
)),
db,
llm_engine: Arc::new(LlmEngine::new()),
});
{
@@ -252,6 +255,15 @@ pub fn run() {
commands::audio::start_native_capture,
commands::audio::stop_native_capture,
commands::audio::list_audio_devices,
// Tasks (canonical SQLite-backed task CRUD)
commands::tasks::create_task_cmd,
commands::tasks::list_tasks_cmd,
commands::tasks::complete_task_cmd,
commands::tasks::delete_task_cmd,
commands::tasks::uncomplete_task_cmd,
commands::tasks::decompose_and_store,
commands::tasks::list_subtasks_cmd,
commands::tasks::complete_subtask_cmd,
// Transcripts (canonical SQLite-backed history) — Day 4
commands::transcripts::add_transcript,
commands::transcripts::list_transcripts,

View File

@@ -384,8 +384,6 @@ textarea, input, [data-no-transition] {
animation: sinhala-spin 2s linear infinite;
}
/* Recording indicator dot (follows mouse via JS, see +layout.svelte) */
/* === Reduced Motion === */
@media (prefers-reduced-motion: reduce) {
*,

View File

@@ -0,0 +1,111 @@
<script>
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
let { parentTaskId, reduceMotion = false } = $props();
let subtasks = $state([]);
let loading = $state(false);
let error = $state('');
let decomposing = $state(false);
async function loadSubtasks() {
loading = true;
error = '';
try {
subtasks = await invoke('list_subtasks_cmd', { parentTaskId });
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function decompose() {
decomposing = true;
error = '';
try {
subtasks = await invoke('decompose_and_store', { parentTaskId });
} catch (e) {
error = String(e);
} finally {
decomposing = false;
}
}
async function checkStep(subtaskId) {
try {
await invoke('complete_subtask_cmd', { subtaskId });
const idx = subtasks.findIndex(s => s.id === subtaskId);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], done: true };
} catch (_) {}
}
function startTimer(subtaskId) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: subtaskId, seconds: 120 }
}));
}
$effect(() => {
if (parentTaskId) loadSubtasks();
});
</script>
<div class="pl-6 mt-1 flex flex-col gap-1">
{#if loading}
<div class="flex items-center gap-2 text-[12px] text-text-tertiary py-1">
<Loader2 size={12} class="animate-spin" aria-hidden="true" />
Loading steps…
</div>
{:else if subtasks.length === 0 && !error}
<button
class="flex items-center gap-1.5 text-[12px] text-text-tertiary hover:text-accent py-1 disabled:opacity-50"
onclick={decompose}
disabled={decomposing}
aria-label="Break down this task into micro-steps"
>
{#if decomposing}
<Loader2 size={12} class="animate-spin" aria-hidden="true" />
Breaking down…
{:else}
<ListTree size={12} aria-hidden="true" />
Break down
{/if}
</button>
{:else if error}
<p class="text-[11px] text-text-tertiary py-1">{error}</p>
{:else}
{#each subtasks as step (step.id)}
<div class="flex items-center gap-2 group py-0.5">
<button
class="w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0
{step.done
? 'bg-success/20 border-success text-success'
: 'border-border-subtle hover:border-accent'}"
onclick={() => checkStep(step.id)}
disabled={step.done}
aria-label={step.done ? 'Step complete' : 'Mark step complete'}
>
{#if step.done}
<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}
<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)}
aria-label="Start 2-minute timer for this step"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<Timer size={10} aria-hidden="true" />
Just Start
</button>
{/if}
</div>
{/each}
{/if}
</div>

View File

@@ -1,13 +1,16 @@
<script>
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import { ChevronDown } from 'lucide-svelte';
import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
let { wipLimit = 3 } = $props();
let newTaskText = $state('');
let showOverflow = $state(false);
let expandedTaskIds = $state(new Set());
let activeTasks = $derived(tasks.filter(t => !t.done));
// Exclude subtasks from WIP count — only top-level tasks count
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
let visibleTasks = $derived(activeTasks.slice(0, wipLimit));
let overflowTasks = $derived(activeTasks.slice(wipLimit));
let hasOverflow = $derived(overflowTasks.length > 0);
@@ -22,6 +25,16 @@
function handleKeydown(e) {
if (e.key === 'Enter') handleAdd();
}
function toggleExpand(taskId) {
const next = new Set(expandedTaskIds);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
expandedTaskIds = next;
}
</script>
<div class="flex flex-col gap-3">
@@ -44,23 +57,42 @@
{:else}
<div class="flex flex-col gap-1">
{#each visibleTasks as task (task.id)}
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
style="transition-duration: var(--duration-ui)">
<button
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
hover:border-accent"
onclick={() => completeTask(task.id)}
aria-label="Complete task"
>
</button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<button
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
onclick={() => deleteTask(task.id)}
aria-label="Delete task"
>
&times;
</button>
<div>
<!-- Task row -->
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
style="transition-duration: var(--duration-ui)">
<button
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
hover:border-accent"
onclick={() => completeTask(task.id)}
aria-label="Complete task"
></button>
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
<!-- Expand/collapse micro-steps toggle -->
<button
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
onclick={() => toggleExpand(task.id)}
aria-label={expandedTaskIds.has(task.id) ? 'Collapse steps' : 'Expand steps'}
style="transition: opacity var(--duration-ui)"
>
{#if expandedTaskIds.has(task.id)}
<ChevronDown size={12} aria-hidden="true" />
{:else}
<ChevronRight size={12} aria-hidden="true" />
{/if}
</button>
<button
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
onclick={() => deleteTask(task.id)}
aria-label="Delete task"
>
&times;
</button>
</div>
<!-- Micro-steps panel (expanded) -->
{#if expandedTaskIds.has(task.id)}
<MicroSteps parentTaskId={task.id} />
{/if}
</div>
{/each}
</div>

View File

@@ -417,7 +417,9 @@
async function finaliseTranscription(audioPath = null) {
if (transcript.trim()) {
if (settings.autoCopy) {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
});
}
const historyId = crypto.randomUUID();
@@ -464,7 +466,11 @@
}
function copyAll() {
if (transcript) invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
if (transcript) {
navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
});
}
}
function clearTranscript() {

View File

@@ -118,7 +118,11 @@
}
function copyAll() {
if (fileTranscript) invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
if (fileTranscript) {
navigator.clipboard.writeText(fileTranscript).catch(() => {
invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
});
}
}
function handleExport(format) {

View File

@@ -178,7 +178,9 @@
}
function copyItem(item) {
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
navigator.clipboard.writeText(item.text).catch(() => {
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
});
}
function removeItem(item) {

View File

@@ -281,14 +281,6 @@
saveSettings();
});
// Sync theme setting to preferences store
$effect(() => {
const map = { Dark: 'dark', Light: 'light', System: 'system' };
const mapped = map[settings.theme] || 'dark';
if (prefs.theme !== mapped) {
updatePreferences({ theme: mapped });
}
});
$effect(() => {
settings.engine;

View File

@@ -201,6 +201,18 @@ function loadTasks() {
export const tasks = $state(loadTasks());
// On boot in the desktop runtime, load canonical tasks from SQLite.
// localStorage remains the warm cache and fallback.
if (typeof window !== "undefined" && hasTauriRuntime()) {
invoke("list_tasks_cmd")
.then((sqliteTasks) => {
tasks.length = 0;
tasks.push(...sqliteTasks);
saveTasks();
})
.catch(() => {});
}
export function saveTasks() {
try {
localStorage.setItem(TASKS_KEY, JSON.stringify(tasks));
@@ -208,7 +220,7 @@ export function saveTasks() {
}
export function addTask(task) {
tasks.unshift({
const newTask = {
id: crypto.randomUUID(),
text: task.text,
bucket: task.bucket || "inbox",
@@ -219,10 +231,22 @@ export function addTask(task) {
doneAt: null,
createdAt: new Date().toISOString(),
sourceTranscriptId: task.sourceTranscriptId || null,
parentTaskId: task.parentTaskId || null,
notes: "",
});
};
tasks.unshift(newTask);
saveTasks();
broadcastTasks();
if (hasTauriRuntime()) {
invoke("create_task_cmd", {
request: {
id: newTask.id,
text: newTask.text,
bucket: newTask.bucket,
sourceTranscriptId: newTask.sourceTranscriptId,
},
}).catch(() => {});
}
}
export function updateTask(id, updates) {
@@ -240,15 +264,24 @@ export function deleteTask(id) {
tasks.splice(idx, 1);
saveTasks();
broadcastTasks();
if (hasTauriRuntime()) {
invoke("delete_task_cmd", { id }).catch(() => {});
}
}
}
export function completeTask(id) {
updateTask(id, { done: true, doneAt: new Date().toISOString() });
if (hasTauriRuntime()) {
invoke("complete_task_cmd", { id }).catch(() => {});
}
}
export function uncompleteTask(id) {
updateTask(id, { done: false, doneAt: null });
if (hasTauriRuntime()) {
invoke("uncomplete_task_cmd", { id }).catch(() => {});
}
}
// ---- BroadcastChannel for multi-window task sync ----

View File

@@ -98,23 +98,29 @@ function persistToSQLite(prefs) {
}, 500);
}
let preferences = $state(readFromDOM());
// Export as const so it is never reassigned — consumers hold a stable reference
// and mutations are tracked by Svelte 5's deep reactivity.
export const preferences = $state(readFromDOM());
// Ensure data-theme and zone attributes are always present on the DOM, even
// when there is no Tauri webview injection script (e.g. browser dev mode).
if (typeof window !== 'undefined') {
applyToDOM(preferences);
}
/** @deprecated Use `preferences` directly — kept for backwards compatibility */
export function getPreferences() {
return preferences;
}
export function updatePreferences(updates) {
preferences = { ...preferences, ...updates };
Object.assign(preferences, updates);
applyToDOM(preferences);
persistToSQLite(preferences);
}
export function updateAccessibility(updates) {
preferences = {
...preferences,
accessibility: { ...preferences.accessibility, ...updates }
};
Object.assign(preferences.accessibility, updates);
applyToDOM(preferences);
persistToSQLite(preferences);
}

View File

@@ -27,15 +27,6 @@
$sveltePage.url.pathname.startsWith("/viewer")
);
// Recording indicator dot (follows mouse)
let mouseX = $state(0);
let mouseY = $state(0);
function handleMouseMove(e) {
mouseX = e.clientX;
mouseY = e.clientY;
}
// Theme — migrate from old class-based to new data-attribute system
// The preferences store handles DOM application via data-theme attribute
$effect(() => {
@@ -262,14 +253,8 @@
});
</script>
<svelte:window onmousemove={handleMouseMove} onkeydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} />
{#if page.recording}
<div
class="fixed w-3 h-3 rounded-full bg-danger animate-pulse-soft pointer-events-none"
style="left: {mouseX + 18}px; top: {mouseY + 18}px; z-index: 100;"
></div>
{/if}
{#if isSecondaryWindow}
<!-- Secondary windows (float, viewer) render children only — no shell chrome -->

View File

@@ -194,7 +194,10 @@
function copySegment(idx) {
if (item?.segments?.[idx]) {
invoke("copy_to_clipboard", { text: item.segments[idx].text.trim() }).catch(() => {});
const text = item.segments[idx].text.trim();
navigator.clipboard.writeText(text).catch(() => {
invoke("copy_to_clipboard", { text }).catch(() => {});
});
}
}