From 954f83d2f4d4fa394fb2c7c01101709b47964e5b Mon Sep 17 00:00:00 2001 From: jake Date: Sat, 21 Mar 2026 14:46:36 +0000 Subject: [PATCH] feat(tasks): add decompose_and_store, list_subtasks, complete_subtask commands Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/llm.rs | 6 ++ src-tauri/src/commands/tasks.rs | 174 +++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 3 + 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/llm.rs b/src-tauri/src/commands/llm.rs index d730a0e..533e308 100644 --- a/src-tauri/src/commands/llm.rs +++ b/src-tauri/src/commands/llm.rs @@ -185,6 +185,12 @@ fn parse_task_suggestions(raw: &str) -> Result, String> { .map_err(|e| format!("Failed to parse LLM task output: {e}")) } +/// Parse LLM output as a JSON array of strings (micro-steps). +/// Public so decompose_and_store in tasks.rs can reuse it. +pub fn parse_steps(raw: &str) -> Result, String> { + parse_string_array(raw) +} + /// Parse LLM output as a JSON array of strings. fn parse_string_array(raw: &str) -> Result, String> { let json = extract_json_array(raw); diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index 857a170..187411d 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -3,8 +3,8 @@ use sqlx::Row; use crate::AppState; use kon_storage::{ - delete_task, insert_task_v2, list_tasks, list_tasks_by_status, reorder_tasks, update_task_v2, - TaskRow, + all_subtasks_done, delete_task, insert_task_v2, list_subtasks as db_list_subtasks, + list_tasks, list_tasks_by_status, reorder_tasks, update_task_v2, TaskRow, }; /// Maximum number of tasks with status "active" — a core design constraint @@ -29,6 +29,7 @@ pub struct TaskResponse { pub sort_order: i64, pub notes: String, pub source_transcript_id: Option, + pub parent_task_id: Option, } fn task_response_from(row: TaskRow) -> TaskResponse { @@ -47,6 +48,7 @@ fn task_response_from(row: TaskRow) -> TaskResponse { sort_order: row.sort_order, notes: row.notes, source_transcript_id: row.source_transcript_id, + parent_task_id: row.parent_task_id, } } @@ -133,6 +135,7 @@ pub async fn create_task( sort_order, notes: String::new(), source_transcript_id, + parent_task_id: None, }) } @@ -213,7 +216,12 @@ pub async fn list_tasks_cmd( all.into_iter().take(lim).collect() }; - Ok(rows.into_iter().map(task_response_from).collect()) + // Exclude subtasks — only top-level tasks appear in the main list. + // Subtasks are accessed via list_subtasks by parent ID. + Ok(rows.into_iter() + .filter(|r| r.parent_task_id.is_none()) + .map(task_response_from) + .collect()) } /// Reorder tasks by setting sort_order based on position in the ID list. @@ -259,3 +267,163 @@ pub async fn uncomplete_task_cmd( .map_err(|e| format!("Uncomplete task failed: {e}")) .map(|_| ()) } + +/// Decompose a task into micro-steps using the LLM, then store as child tasks. +#[tauri::command] +pub async fn decompose_and_store( + state: tauri::State<'_, AppState>, + parent_task_id: String, +) -> Result, String> { + // Read the parent task text + let parent = sqlx::query("SELECT text, project FROM tasks WHERE id = ?") + .bind(&parent_task_id) + .fetch_optional(&state.db) + .await + .map_err(|e| format!("Failed to find parent task: {e}"))? + .ok_or_else(|| "Parent task not found".to_string())?; + + let task_text: String = parent.get("text"); + let project: Option = parent.get("project"); + + // Call the LLM to decompose + if !state.llm_engine.is_loaded() { + return Err("Download an AI model in Settings to break down tasks.".to_string()); + } + + let engine = state.llm_engine.clone(); + let system_prompt = r#"Break this task into 3-7 micro-steps. +Each step MUST start with a specific physical verb (e.g. 'Open', 'Type', 'Click', 'Pick up'). +Each step must be completable in under 5 minutes. +Never use abstract verbs like 'organise', 'plan', 'consider'. +Return as JSON array of strings."#.to_string(); + + let result = kon_llm::inference::run_llm_inference( + engine, + system_prompt, + task_text, + 256, + ) + .await + .map_err(|e| e.to_string())?; + + // Parse the JSON array of step strings + let steps = crate::commands::llm::parse_steps(&result)?; + + // Create child tasks + let mut children = Vec::new(); + for (i, step_text) in steps.iter().enumerate() { + let id = uuid::Uuid::new_v4().to_string(); + insert_task_v2( + &state.db, + &id, + step_text, + "medium", + project.as_deref(), + "pending", + "today", + Some("quick"), + None, + i as i64, + ) + .await + .map_err(|e| e.to_string())?; + + // Set parent_task_id + sqlx::query("UPDATE tasks SET parent_task_id = ? WHERE id = ?") + .bind(&parent_task_id) + .bind(&id) + .execute(&state.db) + .await + .map_err(|e| format!("Set parent_task_id failed: {e}"))?; + + // Re-fetch for accurate timestamps + let row = sqlx::query("SELECT created_at, updated_at FROM tasks WHERE id = ?") + .bind(&id) + .fetch_optional(&state.db) + .await + .map_err(|e| format!("Fetch child task failed: {e}"))?; + + let (created_at, updated_at) = row + .map(|r| (r.get::("created_at"), r.get::("updated_at"))) + .unwrap_or_default(); + + children.push(TaskResponse { + id, + text: step_text.clone(), + priority: "medium".to_string(), + project: project.clone(), + status: "pending".to_string(), + bucket: "today".to_string(), + effort: Some("quick".to_string()), + done: false, + done_at: None, + created_at, + updated_at, + sort_order: i as i64, + notes: String::new(), + source_transcript_id: None, + parent_task_id: Some(parent_task_id.clone()), + }); + } + + Ok(children) +} + +/// List subtasks (micro-steps) for a parent task. +#[tauri::command] +pub async fn list_subtasks( + state: tauri::State<'_, AppState>, + parent_task_id: String, +) -> Result, String> { + let rows = db_list_subtasks(&state.db, &parent_task_id) + .await + .map_err(|e| e.to_string())?; + Ok(rows.into_iter().map(task_response_from).collect()) +} + +/// Complete a subtask. If all siblings are done, auto-complete the parent. +#[tauri::command] +pub async fn complete_subtask( + state: tauri::State<'_, AppState>, + subtask_id: String, +) -> Result { + // Mark the subtask as done + sqlx::query( + "UPDATE tasks SET status = 'done', done = 1, done_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + ) + .bind(&subtask_id) + .execute(&state.db) + .await + .map_err(|e| format!("Complete subtask failed: {e}"))?; + + // Check if this subtask has a parent + let parent_id: Option = sqlx::query_scalar( + "SELECT parent_task_id FROM tasks WHERE id = ?", + ) + .bind(&subtask_id) + .fetch_optional(&state.db) + .await + .map_err(|e| format!("Fetch parent_task_id failed: {e}"))? + .flatten(); + + // If parent exists, check if all siblings are done → auto-complete parent + if let Some(ref pid) = parent_id { + let all_done = all_subtasks_done(&state.db, pid) + .await + .map_err(|e| e.to_string())?; + + if all_done { + sqlx::query( + "UPDATE tasks SET status = 'done', done = 1, done_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + ) + .bind(pid) + .execute(&state.db) + .await + .map_err(|e| format!("Auto-complete parent failed: {e}"))?; + + return Ok(true); // parent was auto-completed + } + } + + Ok(false) // parent not auto-completed +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30e9728..23bd127 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -163,6 +163,9 @@ pub fn run() { commands::tasks::reorder_tasks_cmd, commands::tasks::complete_task_cmd, commands::tasks::uncomplete_task_cmd, + commands::tasks::decompose_and_store, + commands::tasks::list_subtasks, + commands::tasks::complete_subtask, // LLM commands::llm::list_llm_models, commands::llm::download_llm,