From acfe0d57ba99dd9d0b0ed566105c4e4f6ee3754a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 19 Feb 2026 14:03:34 -0800 Subject: [PATCH 01/33] feat: add project sessions, project notes, and per-repo branch timelines - Add ProjectNote model with project-scoped notes for cross-cutting context - Add project_notes table to SQLite schema with cascade delete - Add reason column to project_repos for tracking why repos are attached - Add start_project_session command for project-level sessions - Add create_project_note, list_project_notes, delete_project_note commands - Build project session context with repo listing, existing notes, and chronological branch timeline summaries grouped by repository - Inject project notes into branch-level timeline context - Add project note extraction to session_runner post-completion hooks - Add ProjectNote and ProjectSessionResponse types to frontend - Add project session UI with prompt input, notes display, and deletion - Bump schema version (wipe-and-recreate on mismatch) --- apps/mark/src-tauri/src/lib.rs | 43 ++ apps/mark/src-tauri/src/session_commands.rs | 302 +++++++++++++- apps/mark/src-tauri/src/session_runner.rs | 41 ++ apps/mark/src-tauri/src/store/mod.rs | 61 ++- apps/mark/src-tauri/src/store/models.rs | 43 ++ .../mark/src-tauri/src/store/project_notes.rs | 96 +++++ .../mark/src-tauri/src/store/project_repos.rs | 16 +- apps/mark/src/lib/commands.ts | 34 ++ .../features/projects/ProjectSection.svelte | 382 +++++++++++++++++- apps/mark/src/lib/types.ts | 19 + 10 files changed, 1009 insertions(+), 28 deletions(-) create mode 100644 apps/mark/src-tauri/src/store/project_notes.rs diff --git a/apps/mark/src-tauri/src/lib.rs b/apps/mark/src-tauri/src/lib.rs index 495c5074..913c1404 100644 --- a/apps/mark/src-tauri/src/lib.rs +++ b/apps/mark/src-tauri/src/lib.rs @@ -863,6 +863,45 @@ fn delete_note( Ok(()) } +// ============================================================================= +// Project note commands +// ============================================================================= + +#[tauri::command] +fn create_project_note( + store: tauri::State<'_, Mutex>>>, + project_id: String, + title: String, + content: String, +) -> Result { + let store = get_store(&store)?; + let note = store::ProjectNote::new(&project_id, &title, &content); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + Ok(note) +} + +#[tauri::command] +fn list_project_notes( + store: tauri::State<'_, Mutex>>>, + project_id: String, +) -> Result, String> { + get_store(&store)? + .list_project_notes(&project_id) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn delete_project_note( + store: tauri::State<'_, Mutex>>>, + note_id: String, +) -> Result<(), String> { + get_store(&store)? + .delete_project_note(¬e_id) + .map_err(|e| e.to_string()) +} + /// Delete a review and all its comments, optionally deleting its linked session. #[tauri::command(rename_all = "camelCase")] fn delete_review( @@ -2338,6 +2377,9 @@ pub fn run() { get_branch_timeline, create_note, delete_note, + create_project_note, + list_project_notes, + delete_project_note, delete_review, delete_commit, delete_pending_commit, @@ -2369,6 +2411,7 @@ pub fn run() { session_commands::cancel_session, session_commands::delete_session, session_commands::start_branch_session, + session_commands::start_project_session, // Actions actions::commands::detect_repo_actions, actions::commands::run_branch_action, diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index 7beaadc1..1c166c05 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -290,6 +290,106 @@ pub struct BranchSessionResponse { pub artifact_id: String, } +/// Response from starting a project session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectSessionResponse { + pub session_id: String, + /// The ID of the project note created (if create_note is true). + pub note_id: Option, +} + +/// Start a project-level session. +/// +/// Project sessions operate at the project level rather than a specific branch. +/// The agent receives project context (all repos, existing project notes) and +/// can research, create notes, or provide analysis. +/// +/// When `create_note` is true, an empty ProjectNote stub is created and the +/// agent's output (after `---`) is extracted into it on completion. +#[tauri::command(rename_all = "camelCase")] +pub async fn start_project_session( + store: tauri::State<'_, Mutex>>>, + registry: tauri::State<'_, Arc>, + app_handle: tauri::AppHandle, + project_id: String, + prompt: String, + create_note: bool, + provider: Option, +) -> Result { + let store = get_store(&store)?; + + let project = store + .get_project(&project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + + // Build project context for the prompt + let project_context = build_project_session_context(&store, &project); + + // Build the full prompt + let action_instructions = if create_note { + "The user is requesting research or analysis at the project level. \ + Investigate the prompt below using any tools available, then produce a note \ + summarizing your findings.\n\n\ + To return the note, include a horizontal rule (---) followed by the note content. \ + Begin the note with a markdown H1 heading as the title.\n\n\ + Do NOT create any commits." + } else { + "The user is requesting work at the project level. \ + Use any tools available to fulfill the request below. \ + You may research, analyze, or provide recommendations.\n\n\ + Do NOT create any commits unless explicitly asked." + }; + + let full_prompt = format!( + "\n{action_instructions}\n\nProject information:\n{project_context}\n\n\n{prompt}" + ); + + // Resolve working directory — use the primary repo's clone path or /tmp + let working_dir = project + .clone_path() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + + // Create the session + let mut session = store::Session::new_running(&full_prompt, &working_dir); + if let Some(ref p) = provider { + session = session.with_provider(p); + } + store.create_session(&session).map_err(|e| e.to_string())?; + + // Optionally create a project note stub + let note_id = if create_note { + let note = store::ProjectNote::new(&project_id, &prompt, "").with_session(&session.id); + store + .create_project_note(¬e) + .map_err(|e| e.to_string())?; + Some(note.id) + } else { + None + }; + + session_runner::start_session( + SessionConfig { + session_id: session.id.clone(), + prompt: full_prompt, + working_dir, + agent_session_id: None, + pre_head_sha: None, + provider, + workspace_name: None, + }, + store, + app_handle, + Arc::clone(®istry), + )?; + + Ok(ProjectSessionResponse { + session_id: session.id, + note_id, + }) +} + /// Start a branch-scoped session (note or commit). /// /// This builds the full prompt (action tag + branch history + user prompt), @@ -335,12 +435,14 @@ pub async fn start_branch_session( let base_branch = branch.base_branch.clone(); let store_for_context = Arc::clone(&store); let branch_id_for_context = branch_id.clone(); + let project_id_for_context = branch.project_id.clone(); let ctx = tauri::async_runtime::spawn_blocking(move || { build_remote_branch_context( &workspace_name, &base_branch, &store_for_context, &branch_id_for_context, + &project_id_for_context, ) }) .await @@ -369,7 +471,13 @@ pub async fn start_branch_session( worktree_path = worktree_path.join(subpath); } - let ctx = build_branch_context(&worktree_path, &branch.base_branch, &store, &branch_id); + let ctx = build_branch_context( + &worktree_path, + &branch.base_branch, + &store, + &branch_id, + &branch.project_id, + ); (worktree_path, ctx) }; @@ -477,6 +585,7 @@ fn build_branch_context( base_branch: &str, store: &Arc, branch_id: &str, + project_id: &str, ) -> String { let mut parts = vec![context_preamble()]; let mut timeline: Vec = Vec::new(); @@ -498,6 +607,9 @@ fn build_branch_context( timeline.extend(note_timeline_entries(store, branch_id, false)); timeline.extend(review_timeline_entries(store, branch_id)); + // Project-level notes + timeline.extend(project_note_timeline_entries(store, project_id)); + parts.push(render_timeline(timeline, commit_error)); parts.join("\n\n") } @@ -511,6 +623,7 @@ fn build_remote_branch_context( base_branch: &str, store: &Arc, branch_id: &str, + project_id: &str, ) -> String { let mut parts = vec![context_preamble()]; let mut timeline: Vec = Vec::new(); @@ -550,6 +663,9 @@ fn build_remote_branch_context( timeline.extend(note_timeline_entries(store, branch_id, true)); timeline.extend(review_timeline_entries(store, branch_id)); + // Project-level notes + timeline.extend(project_note_timeline_entries(store, project_id)); + parts.push(render_timeline(timeline, None)); parts.join("\n\n") } @@ -672,6 +788,166 @@ fn build_project_context( lines.join("\n") } +/// Build the context block for a project-level session. +/// +/// Includes: project name, all attached repos (with reasons and per-repo +/// branch timelines), and existing project notes. +fn build_project_session_context(store: &Arc, project: &store::Project) -> String { + let project_name = project.name.trim(); + let project_name = if project_name.is_empty() { + "Unnamed Project" + } else { + project_name + }; + + let mut lines = vec![format!("You are working in project \"{project_name}\".")]; + + // List all repos + let repos = store.list_project_repos(&project.id).unwrap_or_default(); + if repos.is_empty() { + if let Some(ref repo) = project.github_repo { + lines.push(format!("Primary repository: `{repo}`")); + } else { + lines.push("No repositories are attached to this project.".to_string()); + } + } else { + lines.push("Repositories in this project:".to_string()); + for repo in &repos { + let label = format_repo_label(&repo.github_repo, repo.subpath.as_deref()); + let primary_tag = if repo.is_primary { " (primary)" } else { "" }; + let reason_tag = repo + .reason + .as_deref() + .map(|r| format!(" — {r}")) + .unwrap_or_default(); + lines.push(format!("- `{label}`{primary_tag}{reason_tag}")); + } + } + + // Per-repo branch timelines — gives the project-level agent the same + // awareness of branch activity that branch-level agents receive. + let all_branches = store + .list_branches_for_project(&project.id) + .unwrap_or_default(); + + for repo in &repos { + let repo_branches: Vec<_> = all_branches + .iter() + .filter(|b| b.project_repo_id.as_deref() == Some(&repo.id)) + .collect(); + + if repo_branches.is_empty() { + continue; + } + + let repo_label = format_repo_label(&repo.github_repo, repo.subpath.as_deref()); + lines.push(String::new()); + lines.push(format!("## Repository: {repo_label}")); + + for branch in &repo_branches { + lines.push(String::new()); + lines.push(format!("### Branch: {}", branch.branch_name)); + + let timeline = build_branch_timeline_summary(store, branch); + if timeline.is_empty() { + lines.push("No activity on this branch yet.".to_string()); + } else { + lines.push(timeline); + } + } + } + + // Also include branches not associated with any repo (legacy or unlinked) + let unlinked_branches: Vec<_> = all_branches + .iter() + .filter(|b| b.project_repo_id.is_none()) + .collect(); + if !unlinked_branches.is_empty() { + lines.push(String::new()); + lines.push("## Branches (no repo association)".to_string()); + for branch in &unlinked_branches { + lines.push(String::new()); + lines.push(format!("### Branch: {}", branch.branch_name)); + + let timeline = build_branch_timeline_summary(store, branch); + if timeline.is_empty() { + lines.push("No activity on this branch yet.".to_string()); + } else { + lines.push(timeline); + } + } + } + + // Include existing project notes + let notes = store.list_project_notes(&project.id).unwrap_or_default(); + let non_empty_notes: Vec<_> = notes.iter().filter(|n| !n.content.is_empty()).collect(); + if !non_empty_notes.is_empty() { + lines.push(String::new()); + lines.push("## Existing Project Notes".to_string()); + for note in &non_empty_notes { + lines.push(format!("\n### {}\n\n{}", note.title, note.content)); + } + } + + lines.join("\n") +} + +/// Build a compact timeline summary for a single branch, suitable for +/// inclusion in project-level context. +/// +/// Includes commit log (when a local worktree is available), notes, and +/// reviews — but omits project-level notes (those are rendered separately +/// at the project level to avoid duplication). +fn build_branch_timeline_summary(store: &Arc, branch: &store::Branch) -> String { + let mut timeline: Vec = Vec::new(); + let mut commit_error = None; + + // Attempt to include commit log if we can resolve a local worktree + if let Ok(Some(workdir)) = store.get_workdir_for_branch(&branch.id) { + let worktree = std::path::Path::new(&workdir.path); + if worktree.exists() { + match git::get_full_commit_log(worktree, &branch.base_branch) { + Ok(log) if !log.trim().is_empty() => { + timeline.extend(parse_timestamped_log(&log)); + } + Ok(_) => {} + Err(e) => { + log::warn!( + "Failed to get commit log for branch {} in project context: {e}", + branch.branch_name + ); + commit_error = Some(format!("(Error retrieving commit log: {e})")); + } + } + } + } + + // Notes (inlined for project context — the project agent may not have + // access to the branch's local temp files) + timeline.extend(note_timeline_entries(store, &branch.id, true)); + timeline.extend(review_timeline_entries(store, &branch.id)); + + if timeline.is_empty() { + if let Some(err) = commit_error { + return err; + } + return String::new(); + } + + timeline.sort_by_key(|e| e.timestamp); + + let mut section = String::new(); + if let Some(err) = commit_error { + section.push_str(&err); + section.push('\n'); + } + for entry in &timeline { + section.push_str(&entry.content); + section.push('\n'); + } + section.trim_end().to_string() +} + // ============================================================================= // Chronological timeline helpers // ============================================================================= @@ -772,6 +1048,30 @@ fn note_timeline_entries( entries } +/// Convert project notes from the DB into timeline entries. +fn project_note_timeline_entries(store: &Arc, project_id: &str) -> Vec { + let notes = match store.list_project_notes(project_id) { + Ok(n) => n, + Err(e) => { + log::warn!("Failed to list project notes for branch context: {e}"); + return Vec::new(); + } + }; + + let mut entries = Vec::new(); + for note in ¬es { + if note.content.is_empty() { + continue; // skip notes still generating + } + let content = format!("### Project Note: {}\n\n{}", note.title, note.content); + entries.push(TimelineEntry { + timestamp: note.created_at, + content, + }); + } + entries +} + /// Convert code reviews (with comments) from the DB into timeline entries. fn review_timeline_entries(store: &Arc, branch_id: &str) -> Vec { let reviews = match store.list_reviews_for_branch(branch_id) { diff --git a/apps/mark/src-tauri/src/session_runner.rs b/apps/mark/src-tauri/src/session_runner.rs index 0d664237..b40cec8f 100644 --- a/apps/mark/src-tauri/src/session_runner.rs +++ b/apps/mark/src-tauri/src/session_runner.rs @@ -408,6 +408,47 @@ fn run_post_completion_hooks( } } + // --- Project note extraction --- + if let Ok(Some(empty_note)) = store.get_empty_project_note_by_session(session_id) { + if let Ok(messages) = store.get_session_messages(session_id) { + let full_text: String = messages + .iter() + .filter(|m| m.role == MessageRole::Assistant) + .map(|m| m.content.as_str()) + .collect::>() + .join("\n"); + + if let Some(note_content) = extract_note_content(&full_text) { + let (title, body) = extract_note_title(¬e_content); + let final_title = if title.is_empty() { + store + .get_session(session_id) + .ok() + .flatten() + .map(|s| { + let t: String = s.prompt.chars().take(80).collect(); + if s.prompt.len() > 80 { + format!("{t}…") + } else { + t + } + }) + .unwrap_or_else(|| "Untitled Note".to_string()) + } else { + title + }; + log::info!("Session {session_id}: extracted project note \"{final_title}\""); + if let Err(e) = + store.update_project_note_title_and_content(&empty_note.id, &final_title, &body) + { + log::error!("Failed to update project note content: {e}"); + } + } else { + log::warn!("Session {session_id}: project note session completed but no --- found in assistant output"); + } + } + } + // --- Review comment extraction --- if let Ok(Some(review)) = store.get_review_by_session(session_id) { if review.comments.is_empty() { diff --git a/apps/mark/src-tauri/src/store/mod.rs b/apps/mark/src-tauri/src/store/mod.rs index 3b7cfbab..6355f5ce 100644 --- a/apps/mark/src-tauri/src/store/mod.rs +++ b/apps/mark/src-tauri/src/store/mod.rs @@ -6,7 +6,7 @@ //! confirmation. //! //! Tables: schema_version, projects, project_repos, branches, workdirs, commits, -//! sessions, session_messages, notes, reviews, action_contexts, repo_actions. +//! sessions, session_messages, notes, project_notes, reviews, action_contexts, repo_actions. pub mod models; @@ -15,6 +15,7 @@ mod branches; mod commits; mod messages; mod notes; +mod project_notes; mod project_repos; mod projects; mod recent_repos; @@ -61,7 +62,7 @@ impl From for StoreError { /// /// Bump this whenever the schema changes in an incompatible way. /// Many app versions may share the same schema version. -pub const SCHEMA_VERSION: i64 = 13; +pub const SCHEMA_VERSION: i64 = 14; /// The app version of this build, pulled from Cargo.toml at compile time. pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -247,6 +248,7 @@ impl Store { branch_name TEXT NOT NULL, subpath TEXT, is_primary INTEGER NOT NULL DEFAULT 0, + reason TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -337,6 +339,17 @@ impl Store { ); CREATE INDEX IF NOT EXISTS idx_notes_branch ON notes(branch_id); + CREATE TABLE IF NOT EXISTS project_notes ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT, + title TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id); + CREATE TABLE IF NOT EXISTS reviews ( id TEXT PRIMARY KEY, branch_id TEXT NOT NULL REFERENCES branches(id) ON DELETE CASCADE, @@ -410,11 +423,11 @@ impl Store { CREATE INDEX IF NOT EXISTS idx_recent_repos_last_used ON recent_repos(last_used_at DESC); - -- Session cleanup triggers: when a commit, note, or review is - -- deleted (directly or via cascade from branch/project deletion), - -- delete the referenced session if no other row still points at - -- it. Only non-running sessions are cleaned up — a running - -- session may legitimately have no artifacts yet. + -- Session cleanup triggers: when a commit, note, project note, or + -- review is deleted (directly or via cascade from branch/project + -- deletion), delete the referenced session if no other row still + -- points at it. Only non-running sessions are cleaned up — a + -- running session may legitimately have no artifacts yet. CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_commit_delete AFTER DELETE ON commits WHEN OLD.session_id IS NOT NULL @@ -422,9 +435,10 @@ impl Store { DELETE FROM sessions WHERE id = OLD.session_id AND status != 'running' - AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_note_delete @@ -434,9 +448,10 @@ impl Store { DELETE FROM sessions WHERE id = OLD.session_id AND status != 'running' - AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_review_delete @@ -446,9 +461,23 @@ impl Store { DELETE FROM sessions WHERE id = OLD.session_id AND status != 'running' - AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) - AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id); + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_project_note_delete + AFTER DELETE ON project_notes + WHEN OLD.session_id IS NOT NULL + BEGIN + DELETE FROM sessions + WHERE id = OLD.session_id + AND status != 'running' + AND NOT EXISTS (SELECT 1 FROM commits WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM reviews WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id); END; ", )?; diff --git a/apps/mark/src-tauri/src/store/models.rs b/apps/mark/src-tauri/src/store/models.rs index 21d3afe1..cbf759d6 100644 --- a/apps/mark/src-tauri/src/store/models.rs +++ b/apps/mark/src-tauri/src/store/models.rs @@ -131,6 +131,7 @@ pub struct ProjectRepo { pub branch_name: String, pub subpath: Option, pub is_primary: bool, + pub reason: Option, pub created_at: i64, pub updated_at: i64, } @@ -150,6 +151,7 @@ impl ProjectRepo { branch_name: branch_name.to_string(), subpath, is_primary: false, + reason: None, created_at: now, updated_at: now, } @@ -628,6 +630,47 @@ impl Note { } } +// ============================================================================= +// Project Notes +// ============================================================================= + +/// A note scoped to a project (not a specific branch). +/// +/// Project notes capture cross-cutting context, research, or decisions +/// that apply to the project as a whole. They are injected into every +/// branch session's context so the agent has project-level awareness. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectNote { + pub id: String, + pub project_id: String, + pub session_id: Option, + pub title: String, + pub content: String, + pub created_at: i64, + pub updated_at: i64, +} + +impl ProjectNote { + pub fn new(project_id: &str, title: &str, content: &str) -> Self { + let now = now_timestamp(); + Self { + id: Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + session_id: None, + title: title.to_string(), + content: content.to_string(), + created_at: now, + updated_at: now, + } + } + + pub fn with_session(mut self, session_id: &str) -> Self { + self.session_id = Some(session_id.to_string()); + self + } +} + // ============================================================================= // Recent Repos // ============================================================================= diff --git a/apps/mark/src-tauri/src/store/project_notes.rs b/apps/mark/src-tauri/src/store/project_notes.rs new file mode 100644 index 00000000..da83ea7f --- /dev/null +++ b/apps/mark/src-tauri/src/store/project_notes.rs @@ -0,0 +1,96 @@ +//! Project note CRUD operations. + +use rusqlite::{params, OptionalExtension}; + +use super::models::ProjectNote; +use super::{now_timestamp, Store, StoreError}; + +impl Store { + pub fn create_project_note(&self, note: &ProjectNote) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO project_notes (id, project_id, session_id, title, content, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + note.id, + note.project_id, + note.session_id, + note.title, + note.content, + note.created_at, + note.updated_at, + ], + )?; + Ok(()) + } + + pub fn get_project_note(&self, id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, project_id, session_id, title, content, created_at, updated_at + FROM project_notes WHERE id = ?1", + params![id], + Self::row_to_project_note, + ) + .optional() + .map_err(Into::into) + } + + pub fn list_project_notes(&self, project_id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, project_id, session_id, title, content, created_at, updated_at + FROM project_notes WHERE project_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![project_id], Self::row_to_project_note)?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Find an empty project note (content = '') linked to a given session. + pub fn get_empty_project_note_by_session( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, project_id, session_id, title, content, created_at, updated_at + FROM project_notes WHERE session_id = ?1 AND content = ''", + params![session_id], + Self::row_to_project_note, + ) + .optional() + .map_err(Into::into) + } + + pub fn update_project_note_title_and_content( + &self, + id: &str, + title: &str, + content: &str, + ) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE project_notes SET title = ?1, content = ?2, updated_at = ?3 WHERE id = ?4", + params![title, content, now_timestamp(), id], + )?; + Ok(()) + } + + pub fn delete_project_note(&self, id: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM project_notes WHERE id = ?1", params![id])?; + Ok(()) + } + + fn row_to_project_note(row: &rusqlite::Row) -> rusqlite::Result { + Ok(ProjectNote { + id: row.get(0)?, + project_id: row.get(1)?, + session_id: row.get(2)?, + title: row.get(3)?, + content: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + } +} diff --git a/apps/mark/src-tauri/src/store/project_repos.rs b/apps/mark/src-tauri/src/store/project_repos.rs index d076f80a..f6e7affd 100644 --- a/apps/mark/src-tauri/src/store/project_repos.rs +++ b/apps/mark/src-tauri/src/store/project_repos.rs @@ -9,8 +9,8 @@ impl Store { pub fn create_project_repo(&self, repo: &ProjectRepo) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); if let Err(e) = conn.execute( - "INSERT INTO project_repos (id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO project_repos (id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ repo.id, repo.project_id, @@ -18,6 +18,7 @@ impl Store { repo.branch_name, repo.subpath, if repo.is_primary { 1 } else { 0 }, + repo.reason, repo.created_at, repo.updated_at, ], @@ -43,7 +44,7 @@ impl Store { pub fn get_project_repo(&self, id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at + "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at FROM project_repos WHERE id = ?1", params![id], Self::row_to_project_repo, @@ -55,7 +56,7 @@ impl Store { pub fn list_project_repos(&self, project_id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at + "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at FROM project_repos WHERE project_id = ?1 ORDER BY is_primary DESC, created_at ASC", )?; @@ -69,7 +70,7 @@ impl Store { ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, created_at, updated_at + "SELECT id, project_id, github_repo, branch_name, subpath, is_primary, reason, created_at, updated_at FROM project_repos WHERE project_id = ?1 AND is_primary = 1 ORDER BY created_at ASC LIMIT 1", params![project_id], @@ -126,8 +127,9 @@ impl Store { branch_name: row.get(3)?, subpath: row.get(4)?, is_primary: is_primary_i64 == 1, - created_at: row.get(6)?, - updated_at: row.get(7)?, + reason: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, }) } } diff --git a/apps/mark/src/lib/commands.ts b/apps/mark/src/lib/commands.ts index f1bc82e1..9e34e7c7 100644 --- a/apps/mark/src/lib/commands.ts +++ b/apps/mark/src/lib/commands.ts @@ -137,6 +137,40 @@ export function checkMonorepoModules(githubRepo: string): Promise { return invoke('check_monorepo_modules', { githubRepo }); } +// ============================================================================= +// Project notes & sessions +// ============================================================================= + +export function listProjectNotes(projectId: string): Promise { + return invoke('list_project_notes', { projectId }); +} + +export function createProjectNote( + projectId: string, + title: string, + content: string +): Promise { + return invoke('create_project_note', { projectId, title, content }); +} + +export function deleteProjectNote(noteId: string): Promise { + return invoke('delete_project_note', { noteId }); +} + +export function startProjectSession( + projectId: string, + prompt: string, + createNote: boolean = false, + provider?: string +): Promise { + return invoke('start_project_session', { + projectId, + prompt, + createNote, + provider: provider ?? null, + }); +} + // ============================================================================= // Branches // ============================================================================= diff --git a/apps/mark/src/lib/features/projects/ProjectSection.svelte b/apps/mark/src/lib/features/projects/ProjectSection.svelte index 5f18e001..ccfebf5b 100644 --- a/apps/mark/src/lib/features/projects/ProjectSection.svelte +++ b/apps/mark/src/lib/features/projects/ProjectSection.svelte @@ -1,13 +1,17 @@
@@ -162,6 +270,72 @@
{/if} + + +
+
+ + +
+
+ + + {#if generatingNote || displayNotes.length > 0} +
+
+ + Project Notes +
+ {#if generatingNote} +
+
+ Generating note… + +
+
+ {/if} + {#each displayNotes as note (note.id)} +
+
+ {note.title || 'Untitled note'} +
+ {formatRelativeTime(note.createdAt)} + +
+
+ {#if note.content} +
{note.content}
+ {/if} +
+ {/each} +
+ {/if} +
{#each sortedBranches as branch (branch.id)} {#if branch.branchType === 'remote'} @@ -355,6 +529,206 @@ border: 1px solid var(--border-muted); } + /* ── Project prompt ──────────────────────────────────────────────────── */ + + .project-prompt-section { + padding: 0 4px; + } + + .prompt-input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--border-muted); + border-radius: 10px; + background-color: var(--bg-primary); + transition: border-color 0.15s ease; + } + + .prompt-input-wrapper:focus-within { + border-color: var(--border-emphasis); + } + + .prompt-input-wrapper.running { + opacity: 0.7; + border-color: var(--ui-accent); + } + + .prompt-input { + flex: 1; + border: none; + background: none; + color: var(--text-primary); + font-size: var(--size-sm); + font-family: inherit; + line-height: 1.5; + resize: none; + outline: none; + min-height: 20px; + max-height: 120px; + } + + .prompt-input::placeholder { + color: var(--text-faint); + } + + .send-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background-color: var(--ui-accent); + color: var(--bg-deepest); + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s ease; + } + + .send-button:hover:not(:disabled) { + background-color: var(--ui-accent-hover); + } + + .send-button:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .send-button :global(.spinning) { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + /* ── Project notes ───────────────────────────────────────────────────── */ + + .project-notes { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 4px; + } + + .notes-header { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: var(--size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .note-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background-color: var(--bg-primary); + transition: all 0.15s ease; + } + + .note-card:hover { + border-color: var(--border-muted); + } + + .note-card.generating { + opacity: 0.7; + border-color: var(--note-color, var(--ui-accent)); + border-style: dashed; + } + + .note-card.deleting { + opacity: 0.4; + pointer-events: none; + } + + .note-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .note-title { + font-size: var(--size-sm); + font-weight: 500; + color: var(--text-primary); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .note-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + .note-time { + font-size: var(--size-xs); + color: var(--text-faint); + white-space: nowrap; + } + + .note-delete-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 4px; + background: none; + color: var(--text-faint); + cursor: pointer; + opacity: 0; + transition: all 0.15s ease; + } + + .note-card:hover .note-delete-btn { + opacity: 1; + } + + .note-delete-btn:hover { + color: var(--ui-danger); + background-color: var(--bg-hover); + } + + .note-delete-btn:disabled { + cursor: not-allowed; + opacity: 0.3; + } + + .note-content { + font-size: var(--size-xs); + color: var(--text-muted); + line-height: 1.5; + white-space: pre-wrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } + + /* ── Branches list ───────────────────────────────────────────────────── */ + .branches-list { display: flex; flex-direction: column; diff --git a/apps/mark/src/lib/types.ts b/apps/mark/src/lib/types.ts index 34db754c..fe6e6b49 100644 --- a/apps/mark/src/lib/types.ts +++ b/apps/mark/src/lib/types.ts @@ -153,6 +153,25 @@ export interface Issue { labels: string[]; } +// ============================================================================= +// Project notes & sessions +// ============================================================================= + +export interface ProjectNote { + id: string; + projectId: string; + sessionId: string | null; + title: string; + content: string; + createdAt: number; + updatedAt: number; +} + +export interface ProjectSessionResponse { + sessionId: string; + noteId: string | null; +} + // ============================================================================= // Sessions // ============================================================================= From 08f27cc319061bd58ec945d3be0c8845c052570e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 19 Feb 2026 14:16:23 -0800 Subject: [PATCH 02/33] fix: project sessions always produce notes and show progress in notes section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove createNote parameter from startProjectSession, always pass true so every project session generates a note - Remove session running state from prompt input — the textarea is always available, allowing multiple concurrent project sessions - Track active sessions in a Set instead of a single ID - Reload notes immediately after starting a session so the stub appears as a 'Generating note…' card with a spinner in the notes section - Support multiple concurrent generating notes via filter instead of find - Remove unused Loader2 import, .running CSS state, and spin animation --- apps/mark/src/lib/commands.ts | 3 +- .../features/projects/ProjectSection.svelte | 66 +++++++------------ 2 files changed, 23 insertions(+), 46 deletions(-) diff --git a/apps/mark/src/lib/commands.ts b/apps/mark/src/lib/commands.ts index 9e34e7c7..1bc1b584 100644 --- a/apps/mark/src/lib/commands.ts +++ b/apps/mark/src/lib/commands.ts @@ -160,13 +160,12 @@ export function deleteProjectNote(noteId: string): Promise { export function startProjectSession( projectId: string, prompt: string, - createNote: boolean = false, provider?: string ): Promise { return invoke('start_project_session', { projectId, prompt, - createNote, + createNote: true, provider: provider ?? null, }); } diff --git a/apps/mark/src/lib/features/projects/ProjectSection.svelte b/apps/mark/src/lib/features/projects/ProjectSection.svelte index ccfebf5b..41413d46 100644 --- a/apps/mark/src/lib/features/projects/ProjectSection.svelte +++ b/apps/mark/src/lib/features/projects/ProjectSection.svelte @@ -7,7 +7,7 @@ + +{#if reason && !dismissed} +
+ + {reason} + +
+{/if} + + diff --git a/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte b/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte index 1017aa4f..d457aa71 100644 --- a/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte +++ b/apps/mark/src/lib/features/branches/RemoteBranchCard.svelte @@ -21,8 +21,6 @@ Copy, Pencil, FileDiff, - Info, - X, } from 'lucide-svelte'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import Spinner from '../../shared/Spinner.svelte'; @@ -42,6 +40,7 @@ import NoteModal from '../notes/NoteModal.svelte'; import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; import BranchCardHeaderInfo from './BranchCardHeaderInfo.svelte'; + import ReasonBanner from './ReasonBanner.svelte'; import { formatBaseBranch } from './branchCardHelpers'; import { alerts } from '../../shared/alerts.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; @@ -182,10 +181,7 @@ // Repo reason banner // ========================================================================= - let reasonDismissed = $state(false); - async function handleDismissReason() { - reasonDismissed = true; if (branch.projectRepoId) { try { await commands.clearProjectRepoReason(branch.projectRepoId); @@ -675,15 +671,7 @@
- {#if repoLabel?.reason && !reasonDismissed} -
- - {repoLabel.reason} - -
- {/if} + {#if status === 'starting'}
@@ -969,61 +957,6 @@ min-height: 80px; } - /* Repo reason banner */ - .reason-banner { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 10px; - margin-bottom: 10px; - border-radius: 6px; - background-color: color-mix(in srgb, var(--ui-info, #3b82f6) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--ui-info, #3b82f6) 25%, transparent); - } - - .reason-banner :global(.reason-icon) { - color: var(--ui-info, #3b82f6); - flex-shrink: 0; - margin-top: 1px; - } - - .reason-text { - flex: 1; - font-size: var(--size-xs); - color: var(--text-primary); - line-height: 1.4; - min-width: 0; - } - - .reason-dismiss { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - padding: 0; - flex-shrink: 0; - background: none; - border: none; - border-radius: 3px; - color: var(--text-faint); - cursor: pointer; - opacity: 0; - transition: - opacity 0.1s, - color 0.1s, - background-color 0.1s; - } - - .reason-banner:hover .reason-dismiss { - opacity: 1; - } - - .reason-dismiss:hover { - color: var(--text-primary); - background-color: color-mix(in srgb, var(--ui-info, #3b82f6) 15%, transparent); - } - /* Timeline loading / error */ .loading { display: flex;