From 58f1a00cd06e6f269513d00038ddc947a87cbb3b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 16:31:08 +1100 Subject: [PATCH 01/30] feat(mark): add image storage layer with DB migration and Tauri commands Phase 1 of image support: Image model, SQLite migration v18, CRUD operations, filesystem management, and Tauri commands for create/get/list/delete images. Images are stored in the project folder for automatic cleanup on project deletion. Co-Authored-By: Claude Opus 4.6 --- apps/mark/src-tauri/src/lib.rs | 175 ++++++++++++++++++++++++ apps/mark/src-tauri/src/store/images.rs | 88 ++++++++++++ apps/mark/src-tauri/src/store/mod.rs | 140 ++++++++++++++++++- apps/mark/src-tauri/src/store/models.rs | 49 +++++++ 4 files changed, 446 insertions(+), 6 deletions(-) create mode 100644 apps/mark/src-tauri/src/store/images.rs diff --git a/apps/mark/src-tauri/src/lib.rs b/apps/mark/src-tauri/src/lib.rs index 79921c5a..19667239 100644 --- a/apps/mark/src-tauri/src/lib.rs +++ b/apps/mark/src-tauri/src/lib.rs @@ -248,6 +248,19 @@ pub struct ReviewTimelineItem { pub updated_at: i64, } +/// Image with session status resolved. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageTimelineItem { + pub id: String, + pub filename: String, + pub mime_type: String, + pub size_bytes: i64, + pub session_id: Option, + pub session_status: Option, + pub created_at: i64, +} + /// Composite timeline for a branch. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -255,6 +268,7 @@ pub struct BranchTimeline { pub commits: Vec, pub notes: Vec, pub reviews: Vec, + pub images: Vec, } // ============================================================================= @@ -1142,6 +1156,141 @@ fn delete_project_note( .map_err(|e| e.to_string()) } +// ============================================================================= +// Image commands +// ============================================================================= + +const MAX_IMAGE_SIZE: u64 = 10_485_760; // 10 MB + +const ALLOWED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg"]; + +fn mime_type_for_extension(ext: &str) -> &'static str { + match ext { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } +} + +/// Create an image record and copy the file to the project images directory. +#[tauri::command(rename_all = "camelCase")] +fn create_image( + store: tauri::State<'_, Mutex>>>, + branch_id: String, + project_id: String, + file_path: String, +) -> Result { + let store = get_store(&store)?; + + let src = Path::new(&file_path); + if !src.exists() { + return Err(format!("File not found: {file_path}")); + } + + let filename = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid filename".to_string())? + .to_string(); + + let ext = src + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + if !ALLOWED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!( + "Unsupported image format: .{ext}. Allowed: {}", + ALLOWED_IMAGE_EXTENSIONS.join(", ") + )); + } + + let metadata = std::fs::metadata(src).map_err(|e| format!("Cannot read file metadata: {e}"))?; + if metadata.len() > MAX_IMAGE_SIZE { + return Err(format!( + "File too large ({} bytes). Maximum is {} bytes.", + metadata.len(), + MAX_IMAGE_SIZE + )); + } + + let mime_type = mime_type_for_extension(&ext).to_string(); + let size_bytes = metadata.len() as i64; + + let image = store::Image::new(&branch_id, &project_id, &filename, &mime_type, size_bytes); + + // Compute destination path and ensure the images directory exists. + let dest = store::images::image_file_path(&project_id, &image.id, &filename)?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create images directory: {e}"))?; + } + + // Copy the file to the images directory. + std::fs::copy(src, &dest).map_err(|e| format!("Cannot copy image file: {e}"))?; + + // Persist the DB record. + store.create_image(&image).map_err(|e| e.to_string())?; + + Ok(image) +} + +/// Return the filesystem path for an image (the frontend uses convertFileSrc). +#[tauri::command(rename_all = "camelCase")] +fn get_image_path( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result { + let store = get_store(&store)?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = store::images::image_file_path(&image.project_id, &image.id, &image.filename)?; + Ok(path.to_string_lossy().to_string()) +} + +/// Delete an image record and its file on disk. +#[tauri::command(rename_all = "camelCase")] +fn delete_image( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result<(), String> { + let store = get_store(&store)?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + + // Delete the DB record first (triggers session cleanup). + store.delete_image(&image_id).map_err(|e| e.to_string())?; + + // Best-effort file removal. + if let Ok(path) = store::images::image_file_path(&image.project_id, &image.id, &image.filename) + { + if let Err(e) = std::fs::remove_file(&path) { + log::warn!("Failed to remove image file {}: {e}", path.display()); + } + } + + Ok(()) +} + +/// List all images for a branch. +#[tauri::command(rename_all = "camelCase")] +fn list_branch_images( + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result, String> { + get_store(&store)? + .list_images_for_branch(&branch_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( @@ -1422,10 +1571,32 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result = db_images + .into_iter() + .map(|img| { + let (session_id, session_status) = + store.resolve_session_status(img.session_id.as_deref()); + ImageTimelineItem { + id: img.id, + filename: img.filename, + mime_type: img.mime_type, + size_bytes: img.size_bytes, + session_id, + session_status, + created_at: img.created_at, + } + }) + .collect(); + Ok(BranchTimeline { commits, notes, reviews, + images, }) } @@ -2733,6 +2904,10 @@ pub fn run() { create_project_note, list_project_notes, delete_project_note, + create_image, + get_image_path, + delete_image, + list_branch_images, delete_review, delete_commit, delete_pending_commit, diff --git a/apps/mark/src-tauri/src/store/images.rs b/apps/mark/src-tauri/src/store/images.rs new file mode 100644 index 00000000..792e6d6f --- /dev/null +++ b/apps/mark/src-tauri/src/store/images.rs @@ -0,0 +1,88 @@ +//! Image CRUD operations and filesystem helpers. + +use std::path::PathBuf; + +use rusqlite::{params, OptionalExtension}; + +use super::models::Image; +use super::{Store, StoreError}; + +impl Store { + pub fn create_image(&self, image: &Image) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO images (id, branch_id, project_id, session_id, filename, mime_type, size_bytes, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + image.id, + image.branch_id, + image.project_id, + image.session_id, + image.filename, + image.mime_type, + image.size_bytes, + image.created_at, + ], + )?; + Ok(()) + } + + pub fn get_image(&self, id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, branch_id, project_id, session_id, filename, mime_type, size_bytes, created_at + FROM images WHERE id = ?1", + params![id], + Self::row_to_image, + ) + .optional() + .map_err(Into::into) + } + + pub fn list_images_for_branch(&self, branch_id: &str) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, branch_id, project_id, session_id, filename, mime_type, size_bytes, created_at + FROM images WHERE branch_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![branch_id], Self::row_to_image)?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn delete_image(&self, id: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM images WHERE id = ?1", params![id])?; + Ok(()) + } + + fn row_to_image(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Image { + id: row.get(0)?, + branch_id: row.get(1)?, + project_id: row.get(2)?, + session_id: row.get(3)?, + filename: row.get(4)?, + mime_type: row.get(5)?, + size_bytes: row.get(6)?, + created_at: row.get(7)?, + }) + } +} + +/// Compute the filesystem path for an image file. +/// Path: `/images/.` +pub fn image_file_path( + project_id: &str, + image_id: &str, + filename: &str, +) -> Result { + let project_root = crate::git::project_worktree_root_for(project_id) + .map_err(|e| format!("Cannot determine project root: {e}"))?; + let ext = std::path::Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin"); + Ok(project_root + .join("images") + .join(format!("{image_id}.{ext}"))) +} diff --git a/apps/mark/src-tauri/src/store/mod.rs b/apps/mark/src-tauri/src/store/mod.rs index 59e5cc27..74d6cad7 100644 --- a/apps/mark/src-tauri/src/store/mod.rs +++ b/apps/mark/src-tauri/src/store/mod.rs @@ -6,13 +6,14 @@ //! a reset-and-recreate dialog. //! //! Tables: schema_version, projects, project_repos, branches, workdirs, commits, -//! sessions, session_messages, notes, project_notes, reviews, action_contexts, repo_actions. +//! sessions, session_messages, notes, project_notes, reviews, images, action_contexts, repo_actions. pub mod models; mod actions; mod branches; mod commits; +pub mod images; mod messages; mod notes; mod project_notes; @@ -62,7 +63,7 @@ impl From for StoreError { /// /// Bump this whenever the schema changes. /// Many app versions may share the same schema version. -pub const SCHEMA_VERSION: i64 = 18; +pub const SCHEMA_VERSION: i64 = 19; /// Oldest schema version we can migrate forward from. /// @@ -442,6 +443,18 @@ impl Store { CREATE INDEX IF NOT EXISTS idx_recent_repos_last_used ON recent_repos(last_used_at DESC); + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + branch_id TEXT NOT NULL REFERENCES branches(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_images_branch ON images(branch_id); + -- 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 @@ -457,7 +470,8 @@ impl Store { 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); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_note_delete @@ -470,7 +484,8 @@ impl Store { 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); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_review_delete @@ -483,7 +498,8 @@ impl Store { 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); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_project_note_delete @@ -496,7 +512,22 @@ impl Store { 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); + AND NOT EXISTS (SELECT 1 FROM project_notes WHERE session_id = OLD.session_id) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER IF NOT EXISTS trg_cleanup_session_after_image_delete + AFTER DELETE ON images + 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) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); END; ", )?; @@ -527,6 +558,103 @@ impl Store { .ok(); // Ignore error if column already exists (fresh DB) } + if db_version < 19 { + // v18 → v19: add images table and update session cleanup triggers + // to account for the new images table. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + branch_id TEXT NOT NULL REFERENCES branches(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + session_id TEXT, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_images_branch ON images(branch_id);", + )?; + + // Drop and recreate all session cleanup triggers so they include + // the new `images` table in the NOT EXISTS checks. + conn.execute_batch( + "DROP TRIGGER IF EXISTS trg_cleanup_session_after_commit_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_note_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_review_delete; + DROP TRIGGER IF EXISTS trg_cleanup_session_after_project_note_delete; + + CREATE TRIGGER trg_cleanup_session_after_commit_delete + AFTER DELETE ON commits + 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) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_note_delete + AFTER DELETE ON 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) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_review_delete + AFTER DELETE ON reviews + 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) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER 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) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END; + + CREATE TRIGGER trg_cleanup_session_after_image_delete + AFTER DELETE ON images + 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) + AND NOT EXISTS (SELECT 1 FROM images WHERE session_id = OLD.session_id); + END;", + )?; + } + // Stamp the current schema version so future opens skip applied migrations. conn.execute( "UPDATE schema_version SET version = ?1", diff --git a/apps/mark/src-tauri/src/store/models.rs b/apps/mark/src-tauri/src/store/models.rs index acd16ff1..dfeb701c 100644 --- a/apps/mark/src-tauri/src/store/models.rs +++ b/apps/mark/src-tauri/src/store/models.rs @@ -671,6 +671,55 @@ impl ProjectNote { } } +// ============================================================================= +// Images +// ============================================================================= + +/// An image attached to a branch. +/// +/// The image file is stored on disk at +/// `/images/.`. The `project_id` field +/// determines the filesystem location; the `filename` field preserves the +/// original upload name (and its extension). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Image { + pub id: String, + pub branch_id: String, + pub project_id: String, + pub session_id: Option, + pub filename: String, + pub mime_type: String, + pub size_bytes: i64, + pub created_at: i64, +} + +impl Image { + pub fn new( + branch_id: &str, + project_id: &str, + filename: &str, + mime_type: &str, + size_bytes: i64, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + branch_id: branch_id.to_string(), + project_id: project_id.to_string(), + session_id: None, + filename: filename.to_string(), + mime_type: mime_type.to_string(), + size_bytes, + created_at: now_timestamp(), + } + } + + pub fn with_session(mut self, session_id: &str) -> Self { + self.session_id = Some(session_id.to_string()); + self + } +} + // ============================================================================= // Recent Repos // ============================================================================= From 647a60668a84df6af522fe70798190e0b8f22089 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 16:36:58 +1100 Subject: [PATCH 02/30] feat(mark): integrate images into session system and ACP protocol Phase 2 of image support: Extend AgentDriver to accept image content blocks, add image_ids to SessionConfig, base64-encode images before sending to agents, and update start_branch_session/resume_session to accept image IDs. Co-Authored-By: Claude Opus 4.6 --- .../src-tauri/examples/acp_stream_probe.rs | 1 + apps/mark/src-tauri/src/project_mcp.rs | 1 + apps/mark/src-tauri/src/prs.rs | 2 ++ apps/mark/src-tauri/src/session_commands.rs | 6 +++++ apps/mark/src-tauri/src/session_runner.rs | 27 +++++++++++++++++++ crates/acp-client/src/driver.rs | 22 ++++++++++----- crates/acp-client/src/simple.rs | 1 + 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/mark/src-tauri/examples/acp_stream_probe.rs b/apps/mark/src-tauri/examples/acp_stream_probe.rs index 60dec1b5..814d4f2c 100644 --- a/apps/mark/src-tauri/examples/acp_stream_probe.rs +++ b/apps/mark/src-tauri/examples/acp_stream_probe.rs @@ -178,6 +178,7 @@ fn main() -> Result<()> { .run( "probe-session", &prompt, + &[], &workdir, &store, &writer, diff --git a/apps/mark/src-tauri/src/project_mcp.rs b/apps/mark/src-tauri/src/project_mcp.rs index 3bf2a54b..46c9846f 100644 --- a/apps/mark/src-tauri/src/project_mcp.rs +++ b/apps/mark/src-tauri/src/project_mcp.rs @@ -417,6 +417,7 @@ impl ProjectToolsHandler { action_executor: None, action_registry: None, remote_working_dir, + image_ids: vec![], }, Arc::clone(&self.store), self.app_handle.clone(), diff --git a/apps/mark/src-tauri/src/prs.rs b/apps/mark/src-tauri/src/prs.rs index 55cb149d..95c6a307 100644 --- a/apps/mark/src-tauri/src/prs.rs +++ b/apps/mark/src-tauri/src/prs.rs @@ -166,6 +166,7 @@ This is critical - the application parses this to link the PR. action_executor: None, action_registry: None, remote_working_dir, + image_ids: vec![], }, store, app_handle, @@ -556,6 +557,7 @@ The push must succeed before you finish (unless you output the non-fast-forward action_executor: None, action_registry: None, remote_working_dir, + image_ids: vec![], }, store, app_handle, diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index 70b179af..a12adc5a 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -160,6 +160,7 @@ pub fn start_session( action_executor: None, action_registry: None, remote_working_dir: None, + image_ids: vec![], }, store, app_handle, @@ -185,6 +186,7 @@ pub fn resume_session( app_handle: tauri::AppHandle, session_id: String, prompt: String, + image_ids: Option>, ) -> Result<(), String> { let store = get_store(&store)?; @@ -232,6 +234,7 @@ pub fn resume_session( action_executor: None, action_registry: None, remote_working_dir: None, + image_ids: image_ids.unwrap_or_default(), }, store, app_handle, @@ -435,6 +438,7 @@ pub async fn start_project_session( action_executor: Some(Arc::clone(&action_executor)), action_registry: Some(Arc::clone(&action_registry)), remote_working_dir: None, + image_ids: vec![], }, store, app_handle, @@ -464,6 +468,7 @@ pub async fn start_branch_session( prompt: String, session_type: BranchSessionType, provider: Option, + image_ids: Option>, ) -> Result { let store = get_store(&store)?; @@ -651,6 +656,7 @@ pub async fn start_branch_session( action_executor: None, action_registry: None, remote_working_dir, + image_ids: image_ids.unwrap_or_default(), }, store, app_handle, diff --git a/apps/mark/src-tauri/src/session_runner.rs b/apps/mark/src-tauri/src/session_runner.rs index 8b7665b5..ecea49bf 100644 --- a/apps/mark/src-tauri/src/session_runner.rs +++ b/apps/mark/src-tauri/src/session_runner.rs @@ -161,6 +161,9 @@ pub struct SessionConfig { /// the agent operates in the correct repo directory (e.g. /// `/home/bloxer/cash-server` instead of the workspace default). pub remote_working_dir: Option, + /// Image IDs to include in the prompt. The runner reads the image files, + /// base64-encodes them, and passes them as content blocks to the driver. + pub image_ids: Vec, } /// Start a session: persist the user message, spawn the agent, stream to DB. @@ -254,6 +257,29 @@ pub fn start_session( Arc::clone(&store), )); + // Read and base64-encode images for the prompt content blocks. + let mut image_data: Vec<(String, String)> = Vec::new(); + for image_id in &config.image_ids { + if let Ok(Some(image)) = store.get_image(image_id) { + if let Ok(path) = crate::store::images::image_file_path( + &image.project_id, + &image.id, + &image.filename, + ) { + if let Ok(bytes) = std::fs::read(&path) { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + image_data.push((encoded, image.mime_type.clone())); + } else { + log::warn!( + "Failed to read image file for image {image_id}: {}", + path.display() + ); + } + } + } + } + // Cast to trait objects for the driver let store_trait: Arc = store; let writer_trait: Arc = writer; @@ -262,6 +288,7 @@ pub fn start_session( .run( &config.session_id, &config.prompt, + &image_data, &config.working_dir, &store_trait, &writer_trait, diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 83ad795a..17aac637 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use agent_client_protocol::{ - Agent, ClientSideConnection, ContentBlock as AcpContentBlock, Implementation, + Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, Implementation, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, PermissionOptionId, PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate, @@ -74,10 +74,14 @@ pub trait Store: Send + Sync { #[allow(clippy::too_many_arguments)] pub trait AgentDriver { /// Run a single turn: send `prompt`, stream results via `writer`. + /// + /// `images` contains `(base64_data, mime_type)` pairs that are sent as + /// `ContentBlock::Image` entries alongside the text prompt. async fn run( &self, session_id: &str, prompt: &str, + images: &[(String, String)], working_dir: &Path, store: &Arc, writer: &Arc, @@ -215,6 +219,7 @@ impl AgentDriver for AcpDriver { &self, session_id: &str, prompt: &str, + images: &[(String, String)], working_dir: &Path, store: &Arc, writer: &Arc, @@ -373,7 +378,7 @@ impl AgentDriver for AcpDriver { return Ok(()); } result = run_acp_protocol( - &connection, &acp_working_dir, prompt, store, + &connection, &acp_working_dir, prompt, images, store, session_id, agent_session_id, &handler, &self.mcp_servers, ) => result, }; @@ -659,6 +664,7 @@ async fn run_acp_protocol( connection: &ClientSideConnection, working_dir: &Path, prompt: &str, + images: &[(String, String)], store: &Arc, our_session_id: &str, acp_session_id: Option<&str>, @@ -686,10 +692,14 @@ async fn run_acp_protocol( handler.set_live(); - let prompt_request = PromptRequest::new( - agent_session_id, - vec![AcpContentBlock::Text(TextContent::new(prompt))], - ); + let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; + for (data, mime_type) in images { + content_blocks.push(AcpContentBlock::Image(ImageContent::new( + data.as_str(), + mime_type.as_str(), + ))); + } + let prompt_request = PromptRequest::new(agent_session_id, content_blocks); connection .prompt(prompt_request) diff --git a/crates/acp-client/src/simple.rs b/crates/acp-client/src/simple.rs index f2e005f7..72903b53 100644 --- a/crates/acp-client/src/simple.rs +++ b/crates/acp-client/src/simple.rs @@ -270,6 +270,7 @@ pub async fn run_acp_prompt(agent: &AcpAgent, working_dir: &Path, prompt: &str) .run( "simple-session", &prompt, + &[], &working_dir, &store, &writer, From c57d45d206045bc95ccf7c323ecc199cdaab5083 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 16:47:02 +1100 Subject: [PATCH 03/30] feat(mark): add image attachment UI to session modals Phase 3 of image support: Add ImageAttachment component with file picker and clipboard paste support, integrate into NewSessionModal for attaching images when starting sessions, and add frontend command wrappers for image CRUD operations. Co-Authored-By: Claude Opus 4.6 --- apps/mark/package.json | 1 + apps/mark/src-tauri/Cargo.toml | 1 + apps/mark/src-tauri/capabilities/default.json | 3 +- apps/mark/src-tauri/src/lib.rs | 83 ++++++ apps/mark/src/lib/commands.ts | 53 +++- .../lib/features/branches/BranchCard.svelte | 30 ++- .../features/sessions/ImageAttachment.svelte | 249 ++++++++++++++++++ .../features/sessions/NewSessionModal.svelte | 38 ++- .../lib/features/sessions/SessionModal.svelte | 28 +- apps/mark/src/lib/types.ts | 22 ++ pnpm-lock.yaml | 204 +++++++------- 11 files changed, 593 insertions(+), 119 deletions(-) create mode 100644 apps/mark/src/lib/features/sessions/ImageAttachment.svelte diff --git a/apps/mark/package.json b/apps/mark/package.json index 2dbbed95..716f16d1 100644 --- a/apps/mark/package.json +++ b/apps/mark/package.json @@ -28,6 +28,7 @@ "@builderbot/diff-viewer": "workspace:*", "@tauri-apps/api": "^2.10.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-store": "^2.4.2", "ansi-to-html": "^0.7.2", "lucide-svelte": "^0.575.0", diff --git a/apps/mark/src-tauri/Cargo.toml b/apps/mark/src-tauri/Cargo.toml index 11a01110..af2128de 100644 --- a/apps/mark/src-tauri/Cargo.toml +++ b/apps/mark/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tauri-plugin-window-state = "2.4.1" reqwest = { version = "0.13.1", features = ["json"] } tokio = { version = "1.49.0", features = ["sync", "process", "io-util", "macros", "rt-multi-thread", "time"] } tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" # Shared crates git-diff = { path = "../../../crates/git-diff" } diff --git a/apps/mark/src-tauri/capabilities/default.json b/apps/mark/src-tauri/capabilities/default.json index 74b20594..7905ee18 100644 --- a/apps/mark/src-tauri/capabilities/default.json +++ b/apps/mark/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ "clipboard-manager:allow-write-text", "window-state:default", "store:default", - "core:window:allow-set-badge-count" + "core:window:allow-set-badge-count", + "dialog:default" ] } diff --git a/apps/mark/src-tauri/src/lib.rs b/apps/mark/src-tauri/src/lib.rs index 19667239..effe45da 100644 --- a/apps/mark/src-tauri/src/lib.rs +++ b/apps/mark/src-tauri/src/lib.rs @@ -1291,6 +1291,86 @@ fn list_branch_images( .map_err(|e| e.to_string()) } +/// Read an image file and return its data as a base64-encoded data URL. +#[tauri::command(rename_all = "camelCase")] +fn get_image_data( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result { + let store = get_store(&store)?; + let image = store + .get_image(&image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + .map_err(|e| e.to_string())?; + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(format!("data:{};base64,{}", image.mime_type, encoded)) +} + +/// Create an image from base64-encoded data (for browser file input / clipboard paste). +#[tauri::command(rename_all = "camelCase")] +fn create_image_from_data( + store: tauri::State<'_, Mutex>>>, + branch_id: String, + project_id: String, + filename: String, + mime_type: String, + data: String, +) -> Result { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&data) + .map_err(|e| format!("Invalid base64 data: {e}"))?; + + // Validate size + if bytes.len() as u64 > MAX_IMAGE_SIZE { + return Err(format!( + "Image too large: {} bytes (max {})", + bytes.len(), + MAX_IMAGE_SIZE + )); + } + + // Validate extension + let ext = std::path::Path::new(&filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !ALLOWED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!("Unsupported image format: .{ext}")); + } + + let store = get_store(&store)?; + let mime = if mime_type.is_empty() { + mime_type_for_extension(&ext).to_string() + } else { + mime_type + }; + + let image = store::Image::new( + &branch_id, + &project_id, + &filename, + &mime, + bytes.len() as i64, + ); + let path = crate::store::images::image_file_path(&project_id, &image.id, &filename) + .map_err(|e| e.to_string())?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create images directory: {e}"))?; + } + std::fs::write(&path, &bytes).map_err(|e| format!("Failed to save image: {e}"))?; + + store.create_image(&image).map_err(|e| e.to_string())?; + Ok(image) +} + /// Delete a review and all its comments, optionally deleting its linked session. #[tauri::command(rename_all = "camelCase")] fn delete_review( @@ -2530,6 +2610,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_window_state::Builder::new() .with_state_flags( @@ -2906,8 +2987,10 @@ pub fn run() { delete_project_note, create_image, get_image_path, + get_image_data, delete_image, list_branch_images, + create_image_from_data, delete_review, delete_commit, delete_pending_commit, diff --git a/apps/mark/src/lib/commands.ts b/apps/mark/src/lib/commands.ts index 53cbe806..125aa19f 100644 --- a/apps/mark/src/lib/commands.ts +++ b/apps/mark/src/lib/commands.ts @@ -28,6 +28,7 @@ import type { Issue, PrStatus, PollWorkspaceResult, + Image, } from './types'; // ============================================================================= @@ -503,8 +504,12 @@ export function startSession( /** Send a follow-up message to an existing session. * The backend uses the provider that originally created the session. */ -export function resumeSession(sessionId: string, prompt: string): Promise { - return invoke('resume_session', { sessionId, prompt }); +export function resumeSession( + sessionId: string, + prompt: string, + imageIds?: string[] +): Promise { + return invoke('resume_session', { sessionId, prompt, imageIds: imageIds ?? null }); } export function cancelSession(sessionId: string): Promise { @@ -520,13 +525,15 @@ export function startBranchSession( branchId: string, prompt: string, sessionType: BranchSessionType, - provider?: string + provider?: string, + imageIds?: string[] ): Promise { return invoke('start_branch_session', { branchId, prompt, sessionType, provider: provider ?? null, + imageIds: imageIds ?? null, }); } @@ -801,3 +808,43 @@ export function refreshPrStatus(branchId: string): Promise { export function refreshAllPrStatuses(projectId: string): Promise { return invoke('refresh_all_pr_statuses', { projectId }); } + +// ============================================================================= +// Images +// ============================================================================= + +/** Upload a local image file and create a DB record for it. */ +export function createImage(branchId: string, projectId: string, filePath: string): Promise { + return invoke('create_image', { branchId, projectId, filePath }); +} + +/** Get the filesystem path for a stored image. */ +export function getImagePath(imageId: string): Promise { + return invoke('get_image_path', { imageId }); +} + +/** Delete an image record and its stored file. */ +export function deleteImage(imageId: string): Promise { + return invoke('delete_image', { imageId }); +} + +/** List all images for a branch. */ +export function listBranchImages(branchId: string): Promise { + return invoke('list_branch_images', { branchId }); +} + +/** Get the base64-encoded data URL for an image. */ +export function getImageData(imageId: string): Promise { + return invoke('get_image_data', { imageId }); +} + +/** Create an image from base64-encoded data (browser file input / clipboard paste). */ +export function createImageFromData( + branchId: string, + projectId: string, + filename: string, + mimeType: string, + data: string +): Promise { + return invoke('create_image_from_data', { branchId, projectId, filename, mimeType, data }); +} diff --git a/apps/mark/src/lib/features/branches/BranchCard.svelte b/apps/mark/src/lib/features/branches/BranchCard.svelte index cc46ca39..c2165e63 100644 --- a/apps/mark/src/lib/features/branches/BranchCard.svelte +++ b/apps/mark/src/lib/features/branches/BranchCard.svelte @@ -355,6 +355,7 @@ let showNewSession = $state(false); let newSessionMode = $state('commit'); let draftPrompt = $state(''); + let draftImageIds = $state([]); type PendingSessionItemType = 'pending-commit' | 'generating-note' | 'generating-review'; type PendingSessionItem = { key: string; @@ -1199,7 +1200,11 @@ } } - async function startBranchSessionWithPendingItem(mode: BranchSessionType, prompt: string) { + async function startBranchSessionWithPendingItem( + mode: BranchSessionType, + prompt: string, + imageIds: string[] = [] + ) { const pendingKey = `session-start-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; pendingSessionItems = [ ...pendingSessionItems, @@ -1217,7 +1222,8 @@ branch.id, prompt, mode, - getPreferredAgent(agents) ?? undefined + getPreferredAgent(agents) ?? undefined, + imageIds.length > 0 ? imageIds : undefined ); if (!result || !result.sessionId) { @@ -1265,20 +1271,31 @@ newSessionMode = 'review'; showNewSession = false; draftPrompt = ''; + draftImageIds = []; await startBranchSessionWithPendingItem('review', reviewPrompt); } - function handleNewSessionClose(draft: { prompt: string; mode: BranchSessionType }) { + function handleNewSessionClose(draft: { + prompt: string; + mode: BranchSessionType; + imageIds: string[]; + }) { draftPrompt = draft.prompt; newSessionMode = draft.mode; + draftImageIds = draft.imageIds; showNewSession = false; } - function handleNewSessionSubmit(data: { prompt: string; mode: BranchSessionType }) { + function handleNewSessionSubmit(data: { + prompt: string; + mode: BranchSessionType; + imageIds: string[]; + }) { newSessionMode = data.mode; showNewSession = false; draftPrompt = ''; - void startBranchSessionWithPendingItem(data.mode, data.prompt); + draftImageIds = []; + void startBranchSessionWithPendingItem(data.mode, data.prompt, data.imageIds); } // ========================================================================= @@ -2278,6 +2295,7 @@ {branch} mode={newSessionMode} initialPrompt={draftPrompt} + initialImageIds={draftImageIds} remote={isRemote} onClose={handleNewSessionClose} onSubmit={handleNewSessionSubmit} @@ -2288,6 +2306,8 @@ { const closedSessionId = openSessionId; openSessionId = null; diff --git a/apps/mark/src/lib/features/sessions/ImageAttachment.svelte b/apps/mark/src/lib/features/sessions/ImageAttachment.svelte new file mode 100644 index 00000000..e94cd1ce --- /dev/null +++ b/apps/mark/src/lib/features/sessions/ImageAttachment.svelte @@ -0,0 +1,249 @@ + + + + + + + +{#if imageIds.length > 0} +
+ {#each imageIds as imageId} +
+ {#if previews.get(imageId)} + attached + {:else} +
+ {/if} + {#if !disabled} + + {/if} +
+ {/each} + {#if !disabled} + + {/if} +
+{:else if !disabled} + +{/if} + + diff --git a/apps/mark/src/lib/features/sessions/NewSessionModal.svelte b/apps/mark/src/lib/features/sessions/NewSessionModal.svelte index 98ed1143..679b4beb 100644 --- a/apps/mark/src/lib/features/sessions/NewSessionModal.svelte +++ b/apps/mark/src/lib/features/sessions/NewSessionModal.svelte @@ -10,26 +10,36 @@ branch — the branch to create a session on mode — 'commit', 'note', or 'review' (shown as title, not togglable) initialPrompt — pre-fill the textarea (e.g. from a previous close) - onClose — called with { prompt, mode } when dismissed - onSubmit — called with { prompt, mode } when submit is pressed + onClose — called with { prompt, mode, imageIds } when dismissed + onSubmit — called with { prompt, mode, imageIds } when submit is pressed --> {#if items.length === 0 && !onNewNote && !onNewCommit && !onNewReview && pendingDropNotes.length === 0 && pendingItems.length === 0} diff --git a/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte b/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte new file mode 100644 index 00000000..f28bf7b2 --- /dev/null +++ b/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte @@ -0,0 +1,204 @@ + + + + + + + + + diff --git a/apps/mark/src/lib/features/timeline/TimelineRow.svelte b/apps/mark/src/lib/features/timeline/TimelineRow.svelte index 701c04c6..e427deea 100644 --- a/apps/mark/src/lib/features/timeline/TimelineRow.svelte +++ b/apps/mark/src/lib/features/timeline/TimelineRow.svelte @@ -9,6 +9,7 @@ GitCommitVertical, FileText, FileSearch, + Image as ImageLucide, MessageSquare, Trash2, AlertTriangle, @@ -24,7 +25,8 @@ | 'failed-note' | 'review' | 'generating-review' - | 'failed-review'; + | 'failed-review' + | 'image'; interface Props { type: TimelineItemType; @@ -59,6 +61,7 @@ let isReview = $derived( type === 'review' || type === 'generating-review' || type === 'failed-review' ); + let isImage = $derived(type === 'image'); let isPending = $derived( deleting || type === 'pending-commit' || @@ -105,6 +108,7 @@ class:commit-icon={type === 'commit' || type === 'pending-commit'} class:note-icon={type === 'note' || type === 'generating-note'} class:review-icon={type === 'review' || type === 'generating-review'} + class:image-icon={isImage} class:failed-icon={isFailed} > {#if isPending} @@ -117,6 +121,8 @@ {:else if isReview} + {:else if isImage} + {/if} {#if !isLast} @@ -242,6 +248,12 @@ border-color: transparent; } + .timeline-icon.image-icon { + color: #0891b2; + background-color: rgba(8, 145, 178, 0.1); + border-color: transparent; + } + .timeline-row.pending .timeline-icon.commit-icon { background-color: var(--commit-bg); border-color: transparent; From 5f32cbff342af96b9d9552ce17a977498ceb579c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 16:59:20 +1100 Subject: [PATCH 05/30] feat(mark): include image metadata in branch context for agent awareness Co-Authored-By: Claude Opus 4.6 --- apps/mark/src-tauri/src/session_commands.rs | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index a12adc5a..8b3dcdda 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -700,6 +700,7 @@ pub(crate) fn build_branch_context( // Notes and reviews from DB timeline.extend(note_timeline_entries(store, branch_id, None)); timeline.extend(review_timeline_entries(store, branch_id)); + timeline.extend(image_timeline_entries(store, branch_id)); // Project-level notes timeline.extend(project_note_timeline_entries(store, project_id, None)); @@ -760,6 +761,7 @@ pub(crate) fn build_remote_branch_context( Some(workspace_name), )); timeline.extend(review_timeline_entries(store, branch_id)); + timeline.extend(image_timeline_entries(store, branch_id)); // Project-level notes timeline.extend(project_note_timeline_entries( @@ -1044,6 +1046,7 @@ fn build_branch_timeline_summary( // remote workspace when available, otherwise local temp files. timeline.extend(note_timeline_entries(store, &branch.id, workspace_name)); timeline.extend(review_timeline_entries(store, &branch.id)); + timeline.extend(image_timeline_entries(store, &branch.id)); if timeline.is_empty() { if let Some(err) = commit_error { @@ -1368,6 +1371,40 @@ fn review_timeline_entries(store: &Arc, branch_id: &str) -> Vec, branch_id: &str) -> Vec { + let images = match store.list_images_for_branch(branch_id) { + Ok(imgs) => imgs, + Err(e) => { + log::warn!("Failed to list images for branch context: {e}"); + return Vec::new(); + } + }; + + images + .iter() + .map(|img| { + let size_label = if img.size_bytes > 1_000_000 { + format!("{:.1} MB", img.size_bytes as f64 / 1_000_000.0) + } else if img.size_bytes > 1_000 { + format!("{:.0} KB", img.size_bytes as f64 / 1_000.0) + } else { + format!("{} B", img.size_bytes) + }; + TimelineEntry { + timestamp: img.created_at, + content: format!( + "### Image: {}\n\nAttached image ({}, {}). If this image was included in the current prompt, it will appear as an image content block.", + img.filename, img.mime_type, size_label + ), + } + }) + .collect() +} + /// Assemble the full prompt from action instructions + branch context + user prompt. fn build_full_prompt( user_prompt: &str, From b3a1ab4934d065d80b86381fcb8b480d26c92636 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 17:12:26 +1100 Subject: [PATCH 06/30] fix(acp-client): add missing images parameter to SimpleDriverWrapper::run Co-Authored-By: Claude Opus 4.6 --- crates/acp-client/src/simple.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/acp-client/src/simple.rs b/crates/acp-client/src/simple.rs index 72903b53..aa172c1f 100644 --- a/crates/acp-client/src/simple.rs +++ b/crates/acp-client/src/simple.rs @@ -54,6 +54,7 @@ impl AgentDriver for SimpleDriverWrapper { &self, session_id: &str, prompt: &str, + _images: &[(String, String)], working_dir: &Path, store: &Arc, writer: &Arc, From 5c4b197155152eb57d954b047dfea8e5dd3811f6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 17:12:58 +1100 Subject: [PATCH 07/30] fix(mark): allow clippy too_many_arguments for start_branch_session Co-Authored-By: Claude Opus 4.6 --- apps/mark/src-tauri/src/session_commands.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mark/src-tauri/src/session_commands.rs b/apps/mark/src-tauri/src/session_commands.rs index 8b3dcdda..23e7aeb2 100644 --- a/apps/mark/src-tauri/src/session_commands.rs +++ b/apps/mark/src-tauri/src/session_commands.rs @@ -459,6 +459,7 @@ pub async fn start_project_session( /// For remote branches (those with a `workspace_name`), the session runs via /// `blox acp` instead of a local agent binary. Branch context and commit /// detection are skipped since there is no local worktree. +#[allow(clippy::too_many_arguments)] #[tauri::command(rename_all = "camelCase")] pub async fn start_branch_session( store: tauri::State<'_, Mutex>>>, From d950098d54e2585fd3a4283bfc7cdd36e2df1baa Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 5 Mar 2026 17:13:39 +1100 Subject: [PATCH 08/30] fix(mark): resolve Svelte warnings in ImageViewerModal Co-Authored-By: Claude Opus 4.6 --- apps/mark/src/lib/features/timeline/ImageViewerModal.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte b/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte index f28bf7b2..c65eabd4 100644 --- a/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte +++ b/apps/mark/src/lib/features/timeline/ImageViewerModal.svelte @@ -19,7 +19,7 @@ let { imageId, filename, onClose, onDelete }: Props = $props(); let dataUrl = $state(null); let loading = $state(true); - const backdropDismiss = createBackdropDismissHandlers({ onDismiss: onClose }); + const backdropDismiss = createBackdropDismissHandlers({ onDismiss: () => onClose() }); $effect(() => { loading = true; @@ -44,7 +44,7 @@ - +