diff --git a/README.md b/README.md
index ea926d81..97e37cbf 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Thuki utilizes a **Dual-Layer Isolation** model for generative inference:
3. **Start Sandbox (Security-First Launch)**:
Thuki offers a hardened, isolated Docker sandbox as a secure-by-default environment for generative inference. This is ideal if you do not wish to install AI models directly on your host or prefer maximum isolation from the network.
- This bootstraps the sandbox and pulls the models (default: `llama3.2:3b`).
+ This bootstraps the sandbox and pulls the models (default: `gemma3:4b`).
```bash
bun run sandbox:start
diff --git a/sandbox/docker-compose.yml b/sandbox/docker-compose.yml
index cc1fb6d2..40df3cfe 100644
--- a/sandbox/docker-compose.yml
+++ b/sandbox/docker-compose.yml
@@ -40,7 +40,7 @@ services:
# and graceful signal handling (pkill) without requiring a custom Dockerfile.
entrypoint: ["/usr/bin/bash", "-c"]
environment:
- - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2:3b}
+ - OLLAMA_MODEL=${OLLAMA_MODEL:-gemma3:4b}
volumes:
- sandbox_models:/root/.ollama
command:
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 0ab09cb8..1b16991e 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -367,6 +367,12 @@ dependencies = [
"cc",
]
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
[[package]]
name = "colored"
version = "3.1.1"
@@ -1166,6 +1172,16 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "gif"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
[[package]]
name = "gio"
version = "0.18.4"
@@ -1436,6 +1452,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1668,9 +1690,24 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
+ "color_quant",
+ "gif",
+ "image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
]
[[package]]
@@ -2780,6 +2817,12 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3885,6 +3928,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
+ "http-range",
"image",
"jni",
"libc",
@@ -4160,11 +4204,13 @@ dependencies = [
name = "thuki"
version = "0.1.0"
dependencies = [
+ "base64 0.22.1",
"core-foundation 0.10.1",
"core-graphics",
"dirs",
"dotenvy",
"futures-util",
+ "image",
"mockito",
"reqwest",
"rusqlite",
@@ -4921,6 +4967,12 @@ dependencies = [
"windows-core 0.61.2",
]
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -5667,3 +5719,18 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index f3fdb65d..a12691bf 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
-tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
+tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "protocol-asset"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.13.2", features = ["json", "stream"] }
@@ -29,6 +29,8 @@ dotenvy = "0.15.7"
rusqlite = { version = "0.35", features = ["bundled"] }
uuid = { version = "1", features = ["v4"] }
dirs = "6"
+image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
+base64 = "0.22"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index b3d44af2..a4d1f3fe 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken;
/// Default configuration constants as the application currently lacks a Settings UI.
pub const DEFAULT_OLLAMA_URL: &str = "http://127.0.0.1:11434";
-pub const DEFAULT_MODEL_NAME: &str = "llama3.2:3b";
+pub const DEFAULT_MODEL_NAME: &str = "gemma3:4b";
const DEFAULT_SYSTEM_PROMPT: &str = "You are Thuki (thư ký), a personal desktop secretary that \
lives as a floating overlay on macOS. You are fast, sharp, and helpful.\n\nResponse style:\n- Be \
concise. You appear in a small floating window — keep responses scannable.\n- Use short paragraphs, \
@@ -35,10 +35,16 @@ pub enum StreamChunk {
}
/// A single message in the Ollama `/api/chat` conversation format.
+///
+/// The optional `images` field carries base64-encoded image data for
+/// multimodal models (e.g. `gemma3:4b`). When absent or empty, the
+/// message is text-only.
#[derive(Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub images: Option>,
}
/// Request payload for Ollama `/api/chat` endpoint.
@@ -234,13 +240,16 @@ pub async fn stream_ollama_chat(
}
/// Streams a chat response from the local Ollama backend. Appends the user
-/// message and assistant response to conversation history only after successful
-/// completion. Uses an epoch counter to prevent stale writes after a reset.
+/// message and assistant response to conversation history after completion
+/// or cancellation (retaining context for follow-up requests). Uses an epoch
+/// counter to prevent stale writes after a reset.
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg_attr(not(coverage), tauri::command)]
+#[allow(clippy::too_many_arguments)]
pub async fn ask_ollama(
message: String,
quoted_text: Option,
+ image_paths: Option>,
on_event: Channel,
client: State<'_, reqwest::Client>,
generation: State<'_, GenerationState>,
@@ -257,20 +266,31 @@ pub async fn ask_ollama(
_ => message,
};
+ // Base64-encode attached images for the Ollama multimodal API.
+ let images = match image_paths {
+ Some(ref paths) if !paths.is_empty() => {
+ Some(crate::images::encode_images_as_base64(paths)?)
+ }
+ _ => None,
+ };
+
let user_msg = ChatMessage {
role: "user".to_string(),
content,
+ images,
};
// Snapshot the current epoch and build the messages array for Ollama.
// The user message is NOT yet committed to history — it is only added
- // after a successful response to prevent orphaned messages on errors.
+ // after a response (including partial/cancelled) to prevent orphaned
+ // messages on errors.
let (epoch_at_start, messages) = {
let conv = history.messages.lock().unwrap();
let epoch = history.epoch.load(Ordering::SeqCst);
let mut msgs = vec![ChatMessage {
role: "system".to_string(),
content: system_prompt.0.clone(),
+ images: None,
}];
msgs.extend(conv.clone());
msgs.push(user_msg.clone());
@@ -296,10 +316,17 @@ pub async fn ask_ollama(
let current_epoch = history.epoch.load(Ordering::SeqCst);
if current_epoch == epoch_at_start && !accumulated.is_empty() {
let mut conv = history.messages.lock().unwrap();
- conv.push(user_msg);
+ // Strip images from the persisted context — only the current turn's
+ // images are sent to Ollama; replaying base64 blobs on every
+ // subsequent turn would balloon payload size unnecessarily.
+ conv.push(ChatMessage {
+ images: None,
+ ..user_msg
+ });
conv.push(ChatMessage {
role: "assistant".to_string(),
content: accumulated,
+ images: None,
});
}
@@ -371,6 +398,7 @@ mod tests {
let messages = vec![ChatMessage {
role: "user".to_string(),
content: "hi".to_string(),
+ images: None,
}];
let accumulated = stream_ollama_chat(
@@ -804,10 +832,12 @@ mod tests {
ChatMessage {
role: "system".to_string(),
content: "Be helpful".to_string(),
+ images: None,
},
ChatMessage {
role: "user".to_string(),
content: "hi".to_string(),
+ images: None,
},
];
@@ -899,8 +929,14 @@ mod tests {
assert!(second_clone.is_cancelled());
}
+ /// Guard to serialize tests that mutate `THUKI_SYSTEM_PROMPT` env var.
+ /// Rust runs tests in parallel by default; without serialization these
+ /// tests race on the shared environment variable.
+ static ENV_LOCK: StdMutex<()> = StdMutex::new(());
+
#[test]
fn load_system_prompt_returns_default_when_unset() {
+ let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("THUKI_SYSTEM_PROMPT");
let prompt = load_system_prompt();
@@ -909,6 +945,7 @@ mod tests {
#[test]
fn load_system_prompt_reads_env_var() {
+ let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("THUKI_SYSTEM_PROMPT", "Custom prompt");
let prompt = load_system_prompt();
@@ -919,6 +956,7 @@ mod tests {
#[test]
fn load_system_prompt_ignores_empty_env_var() {
+ let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("THUKI_SYSTEM_PROMPT", " ");
let prompt = load_system_prompt();
@@ -940,6 +978,7 @@ mod tests {
h.messages.lock().unwrap().push(ChatMessage {
role: "user".to_string(),
content: "hi".to_string(),
+ images: None,
});
h.epoch.fetch_add(1, Ordering::SeqCst);
diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs
index 3b4c0470..f8733031 100644
--- a/src-tauri/src/database.rs
+++ b/src-tauri/src/database.rs
@@ -29,20 +29,27 @@ pub struct PersistedMessage {
pub role: String,
pub content: String,
pub quoted_text: Option,
+ pub image_paths: Option,
pub created_at: i64,
}
-/// Opens (or creates) the SQLite database at `~/.thuki/thuki.db` and runs
-/// migrations. Returns the ready-to-use connection.
+/// Opens (or creates) the SQLite database at `/thuki.db` and
+/// runs migrations. If an existing database is found at the legacy location
+/// (`~/.thuki/thuki.db`), it is moved to the new location automatically.
///
/// # Errors
///
-/// Returns an error if the home directory cannot be determined, the
-/// `~/.thuki/` directory cannot be created, or SQLite initialisation fails.
+/// Returns an error if the data directory cannot be created or SQLite
+/// initialisation fails.
#[cfg_attr(coverage_nightly, coverage(off))]
-pub fn open_database() -> SqlResult {
- let db_path =
- resolve_db_path().map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?;
+pub fn open_database(app_data_dir: &std::path::Path) -> SqlResult {
+ std::fs::create_dir_all(app_data_dir)
+ .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?;
+
+ let db_path = app_data_dir.join("thuki.db");
+
+ // One-time migration: move database from the legacy ~/.thuki/ location.
+ migrate_legacy_db(&db_path);
let conn = Connection::open(&db_path)?;
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
@@ -61,45 +68,75 @@ pub fn open_in_memory() -> SqlResult {
Ok(conn)
}
-/// Resolves the database file path, creating `~/.thuki/` if it does not exist.
+/// Moves the database from `~/.thuki/thuki.db` to the Tauri app data
+/// directory if the legacy file exists and the target does not.
#[cfg_attr(coverage_nightly, coverage(off))]
-fn resolve_db_path() -> std::io::Result {
- let home = dirs::home_dir().ok_or_else(|| {
- std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found")
- })?;
- let dir = home.join(".thuki");
- std::fs::create_dir_all(&dir)?;
- Ok(dir.join("thuki.db"))
+fn migrate_legacy_db(new_path: &std::path::Path) {
+ if new_path.exists() {
+ return;
+ }
+ let legacy_path = match dirs::home_dir() {
+ Some(home) => home.join(".thuki").join("thuki.db"),
+ None => return,
+ };
+ if !legacy_path.exists() {
+ return;
+ }
+ // Move the database file. If the move fails (e.g. cross-device), fall
+ // back to copy + delete so the migration succeeds across filesystem
+ // boundaries.
+ if std::fs::rename(&legacy_path, new_path).is_err()
+ && std::fs::copy(&legacy_path, new_path).is_ok()
+ {
+ let _ = std::fs::remove_file(&legacy_path);
+ }
+ // Also move the WAL and SHM journal files if they exist.
+ for ext in &["-wal", "-shm"] {
+ let legacy_journal = legacy_path.with_extension(format!("db{ext}"));
+ if legacy_journal.exists() {
+ let new_journal = new_path.with_extension(format!("db{ext}"));
+ if std::fs::rename(&legacy_journal, &new_journal).is_err()
+ && std::fs::copy(&legacy_journal, &new_journal).is_ok()
+ {
+ let _ = std::fs::remove_file(&legacy_journal);
+ }
+ }
+ }
}
/// Creates the schema tables if they do not already exist.
fn run_migrations(conn: &Connection) -> SqlResult<()> {
- conn.execute_batch(
- "CREATE TABLE IF NOT EXISTS conversations (
- id TEXT PRIMARY KEY,
- title TEXT,
- model TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- meta TEXT
- );
-
- CREATE TABLE IF NOT EXISTS messages (
- id TEXT PRIMARY KEY,
- conversation_id TEXT NOT NULL
- REFERENCES conversations(id) ON DELETE CASCADE,
- role TEXT NOT NULL,
- content TEXT NOT NULL,
- quoted_text TEXT,
- created_at INTEGER NOT NULL
- );
-
- CREATE INDEX IF NOT EXISTS idx_messages_conversation
- ON messages(conversation_id, created_at);
-
- CREATE INDEX IF NOT EXISTS idx_conversations_updated
- ON conversations(updated_at DESC);",
- )
+ // Static schema DDL — compiled into a single &str at build time via concat!.
+ const SCHEMA_DDL: &str = concat!(
+ "CREATE TABLE IF NOT EXISTS conversations (",
+ " id TEXT PRIMARY KEY, title TEXT, model TEXT NOT NULL,",
+ " created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, meta TEXT);",
+ "CREATE TABLE IF NOT EXISTS messages (",
+ " id TEXT PRIMARY KEY,",
+ " conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,",
+ " role TEXT NOT NULL, content TEXT NOT NULL, quoted_text TEXT,",
+ " created_at INTEGER NOT NULL);",
+ "CREATE INDEX IF NOT EXISTS idx_messages_conversation",
+ " ON messages(conversation_id, created_at);",
+ "CREATE INDEX IF NOT EXISTS idx_conversations_updated",
+ " ON conversations(updated_at DESC);",
+ );
+ conn.execute_batch(SCHEMA_DDL)?;
+
+ // Migration: add image_paths column to messages table.
+ // ALTER TABLE with IF NOT EXISTS is not supported in SQLite, so we check
+ // the column existence via pragma and only add if missing.
+ let has_image_paths: bool = conn
+ .prepare("PRAGMA table_info(messages)")?
+ .query_map([], |row| row.get::<_, String>(1))?
+ .filter_map(|r| r.ok())
+ .any(|name| name == "image_paths");
+
+ if !has_image_paths {
+ conn.execute_batch("ALTER TABLE messages ADD COLUMN image_paths TEXT;")?;
+ }
+
+ Ok(())
}
// ─── Conversation CRUD ──────────────────────────────────────────────────────
@@ -191,13 +228,14 @@ pub fn insert_message(
role: &str,
content: &str,
quoted_text: Option<&str>,
+ image_paths: Option<&str>,
) -> SqlResult {
let id = uuid::Uuid::new_v4().to_string();
let now = now_millis();
conn.execute(
- "INSERT INTO messages (id, conversation_id, role, content, quoted_text, created_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
- params![id, conversation_id, role, content, quoted_text, now],
+ "INSERT INTO messages (id, conversation_id, role, content, quoted_text, image_paths, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
+ params![id, conversation_id, role, content, quoted_text, image_paths, now],
)?;
conn.execute(
"UPDATE conversations SET updated_at = ?1 WHERE id = ?2",
@@ -211,16 +249,16 @@ pub fn insert_message(
pub fn insert_messages_batch(
conn: &Connection,
conversation_id: &str,
- messages: &[(String, String, Option)], // (role, content, quoted_text)
+ messages: &[(String, String, Option, Option)], // (role, content, quoted_text, image_paths)
) -> SqlResult<()> {
let tx = conn.unchecked_transaction()?;
let now = now_millis();
{
let mut stmt = tx.prepare(
- "INSERT INTO messages (id, conversation_id, role, content, quoted_text, created_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ "INSERT INTO messages (id, conversation_id, role, content, quoted_text, image_paths, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
- for (role, content, quoted_text) in messages {
+ for (role, content, quoted_text, image_paths) in messages {
let id = uuid::Uuid::new_v4().to_string();
stmt.execute(params![
id,
@@ -228,6 +266,7 @@ pub fn insert_messages_batch(
role,
content,
quoted_text.as_deref(),
+ image_paths.as_deref(),
now
])?;
}
@@ -243,7 +282,7 @@ pub fn insert_messages_batch(
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn load_messages(conn: &Connection, conversation_id: &str) -> SqlResult> {
let mut stmt = conn.prepare(
- "SELECT id, role, content, quoted_text, created_at
+ "SELECT id, role, content, quoted_text, image_paths, created_at
FROM messages
WHERE conversation_id = ?1
ORDER BY created_at ASC",
@@ -254,12 +293,30 @@ pub fn load_messages(conn: &Connection, conversation_id: &str) -> SqlResult SqlResult> {
+ let mut stmt =
+ conn.prepare("SELECT image_paths FROM messages WHERE image_paths IS NOT NULL")?;
+ let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
+
+ let mut paths = Vec::new();
+ for row in rows {
+ let json_str = row?;
+ if let Ok(arr) = serde_json::from_str::>(&json_str) {
+ paths.extend(arr);
+ }
+ }
+ Ok(paths)
+}
+
// ─── Helpers ────────────────────────────────────────────────────────────────
/// Maps a row from the conversations query to a `ConversationSummary`.
@@ -286,6 +343,7 @@ fn now_millis() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
+ use std::fs;
#[test]
fn migrations_create_tables() {
@@ -312,21 +370,21 @@ mod tests {
#[test]
fn create_and_list_conversations() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, Some("Test Chat"), "llama3.2:3b").unwrap();
+ let id = create_conversation(&conn, Some("Test Chat"), "gemma3:4b").unwrap();
assert!(!id.is_empty());
let convos = list_conversations(&conn, None).unwrap();
assert_eq!(convos.len(), 1);
assert_eq!(convos[0].title.as_deref(), Some("Test Chat"));
- assert_eq!(convos[0].model, "llama3.2:3b");
+ assert_eq!(convos[0].model, "gemma3:4b");
assert_eq!(convos[0].message_count, 0);
}
#[test]
fn list_conversations_with_search_filter() {
let conn = open_in_memory().unwrap();
- create_conversation(&conn, Some("Rust Code Help"), "llama3.2:3b").unwrap();
- create_conversation(&conn, Some("Draft Email"), "llama3.2:3b").unwrap();
+ create_conversation(&conn, Some("Rust Code Help"), "gemma3:4b").unwrap();
+ create_conversation(&conn, Some("Draft Email"), "gemma3:4b").unwrap();
let results = list_conversations(&conn, Some("rust")).unwrap();
assert_eq!(results.len(), 1);
@@ -340,8 +398,8 @@ mod tests {
#[test]
fn search_escapes_sql_wildcards() {
let conn = open_in_memory().unwrap();
- create_conversation(&conn, Some("100% done"), "llama3.2:3b").unwrap();
- create_conversation(&conn, Some("something else"), "llama3.2:3b").unwrap();
+ create_conversation(&conn, Some("100% done"), "gemma3:4b").unwrap();
+ create_conversation(&conn, Some("something else"), "gemma3:4b").unwrap();
let results = list_conversations(&conn, Some("100%")).unwrap();
assert_eq!(results.len(), 1);
@@ -351,7 +409,7 @@ mod tests {
#[test]
fn update_conversation_title() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, Some("Old Title"), "llama3.2:3b").unwrap();
+ let id = create_conversation(&conn, Some("Old Title"), "gemma3:4b").unwrap();
super::update_conversation_title(&conn, &id, "New Title").unwrap();
@@ -362,9 +420,9 @@ mod tests {
#[test]
fn delete_conversation_cascades_messages() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, Some("To Delete"), "llama3.2:3b").unwrap();
- insert_message(&conn, &id, "user", "hello", None).unwrap();
- insert_message(&conn, &id, "assistant", "hi there", None).unwrap();
+ let id = create_conversation(&conn, Some("To Delete"), "gemma3:4b").unwrap();
+ insert_message(&conn, &id, "user", "hello", None, None).unwrap();
+ insert_message(&conn, &id, "assistant", "hi there", None, None).unwrap();
delete_conversation(&conn, &id).unwrap();
@@ -378,10 +436,26 @@ mod tests {
#[test]
fn insert_and_load_messages() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, None, "llama3.2:3b").unwrap();
-
- insert_message(&conn, &id, "user", "What is Rust?", Some("quoted context")).unwrap();
- insert_message(&conn, &id, "assistant", "Rust is a systems language.", None).unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
+
+ insert_message(
+ &conn,
+ &id,
+ "user",
+ "What is Rust?",
+ Some("quoted context"),
+ None,
+ )
+ .unwrap();
+ insert_message(
+ &conn,
+ &id,
+ "assistant",
+ "Rust is a systems language.",
+ None,
+ None,
+ )
+ .unwrap();
let msgs = load_messages(&conn, &id).unwrap();
assert_eq!(msgs.len(), 2);
@@ -396,15 +470,16 @@ mod tests {
#[test]
fn insert_messages_batch_is_atomic() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, None, "llama3.2:3b").unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
let batch = vec![
- ("user".to_string(), "hello".to_string(), None),
- ("assistant".to_string(), "hi".to_string(), None),
+ ("user".to_string(), "hello".to_string(), None, None),
+ ("assistant".to_string(), "hi".to_string(), None, None),
(
"user".to_string(),
"how are you?".to_string(),
Some("context".to_string()),
+ None,
),
];
insert_messages_batch(&conn, &id, &batch).unwrap();
@@ -421,13 +496,13 @@ mod tests {
#[test]
fn insert_message_touches_updated_at() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, None, "llama3.2:3b").unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
let before = list_conversations(&conn, None).unwrap()[0].updated_at;
// Small delay to ensure timestamp changes.
std::thread::sleep(std::time::Duration::from_millis(5));
- insert_message(&conn, &id, "user", "test", None).unwrap();
+ insert_message(&conn, &id, "user", "test", None, None).unwrap();
let after = list_conversations(&conn, None).unwrap()[0].updated_at;
assert!(after >= before);
@@ -436,9 +511,9 @@ mod tests {
#[test]
fn conversations_ordered_by_most_recent() {
let conn = open_in_memory().unwrap();
- let id1 = create_conversation(&conn, Some("First"), "llama3.2:3b").unwrap();
+ let id1 = create_conversation(&conn, Some("First"), "gemma3:4b").unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
- create_conversation(&conn, Some("Second"), "llama3.2:3b").unwrap();
+ create_conversation(&conn, Some("Second"), "gemma3:4b").unwrap();
let convos = list_conversations(&conn, None).unwrap();
assert_eq!(convos[0].title.as_deref(), Some("Second"));
@@ -446,7 +521,7 @@ mod tests {
// Updating a message in the first conversation bumps it to the top.
std::thread::sleep(std::time::Duration::from_millis(5));
- insert_message(&conn, &id1, "user", "bump", None).unwrap();
+ insert_message(&conn, &id1, "user", "bump", None, None).unwrap();
let convos = list_conversations(&conn, None).unwrap();
assert_eq!(convos[0].title.as_deref(), Some("First"));
@@ -455,7 +530,7 @@ mod tests {
#[test]
fn create_conversation_with_no_title() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, None, "llama3.2:3b").unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
let convos = list_conversations(&conn, None).unwrap();
assert_eq!(convos.len(), 1);
assert!(convos[0].title.is_none());
@@ -472,7 +547,7 @@ mod tests {
#[test]
fn load_messages_empty_conversation() {
let conn = open_in_memory().unwrap();
- let id = create_conversation(&conn, None, "llama3.2:3b").unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
let msgs = load_messages(&conn, &id).unwrap();
assert!(msgs.is_empty());
}
@@ -485,11 +560,137 @@ mod tests {
}
#[test]
- fn resolve_db_path_creates_directory() {
- // This test verifies the path resolution logic — it creates ~/.thuki/
- // which is acceptable in test environments.
- let path = resolve_db_path().unwrap();
- assert!(path.ends_with("thuki.db"));
- assert!(path.parent().unwrap().exists());
+ fn insert_message_with_image_paths() {
+ let conn = open_in_memory().unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
+
+ let paths_json = r#"["/images/a.jpg","/images/b.jpg"]"#;
+ insert_message(&conn, &id, "user", "look at this", None, Some(paths_json)).unwrap();
+
+ let msgs = load_messages(&conn, &id).unwrap();
+ assert_eq!(msgs.len(), 1);
+ assert_eq!(msgs[0].image_paths.as_deref(), Some(paths_json));
+ }
+
+ #[test]
+ fn insert_message_without_image_paths() {
+ let conn = open_in_memory().unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
+
+ insert_message(&conn, &id, "user", "hello", None, None).unwrap();
+
+ let msgs = load_messages(&conn, &id).unwrap();
+ assert_eq!(msgs.len(), 1);
+ assert!(msgs[0].image_paths.is_none());
+ }
+
+ #[test]
+ fn batch_insert_with_image_paths() {
+ let conn = open_in_memory().unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
+
+ let batch = vec![
+ (
+ "user".to_string(),
+ "look".to_string(),
+ None,
+ Some(r#"["/images/x.jpg"]"#.to_string()),
+ ),
+ ("assistant".to_string(), "I see".to_string(), None, None),
+ ];
+ insert_messages_batch(&conn, &id, &batch).unwrap();
+
+ let msgs = load_messages(&conn, &id).unwrap();
+ assert_eq!(msgs.len(), 2);
+ assert_eq!(msgs[0].image_paths.as_deref(), Some(r#"["/images/x.jpg"]"#));
+ assert!(msgs[1].image_paths.is_none());
+ }
+
+ #[test]
+ fn get_all_image_paths_collects_from_all_conversations() {
+ let conn = open_in_memory().unwrap();
+ let c1 = create_conversation(&conn, None, "gemma3:4b").unwrap();
+ let c2 = create_conversation(&conn, None, "gemma3:4b").unwrap();
+
+ insert_message(
+ &conn,
+ &c1,
+ "user",
+ "msg1",
+ None,
+ Some(r#"["/images/a.jpg"]"#),
+ )
+ .unwrap();
+ insert_message(
+ &conn,
+ &c2,
+ "user",
+ "msg2",
+ None,
+ Some(r#"["/images/b.jpg","/images/c.jpg"]"#),
+ )
+ .unwrap();
+ // Message without images.
+ insert_message(&conn, &c1, "assistant", "reply", None, None).unwrap();
+
+ let paths = get_all_image_paths(&conn).unwrap();
+ assert_eq!(paths.len(), 3);
+ assert!(paths.contains(&"/images/a.jpg".to_string()));
+ assert!(paths.contains(&"/images/b.jpg".to_string()));
+ assert!(paths.contains(&"/images/c.jpg".to_string()));
+ }
+
+ #[test]
+ fn get_all_image_paths_empty_when_no_images() {
+ let conn = open_in_memory().unwrap();
+ let id = create_conversation(&conn, None, "gemma3:4b").unwrap();
+ insert_message(&conn, &id, "user", "hello", None, None).unwrap();
+
+ let paths = get_all_image_paths(&conn).unwrap();
+ assert!(paths.is_empty());
+ }
+
+ #[test]
+ fn migrate_legacy_db_moves_existing_file() {
+ let tmp = std::env::temp_dir().join(format!("thuki-migrate-{}", uuid::Uuid::new_v4()));
+ fs::create_dir_all(&tmp).unwrap();
+
+ // Create a fake legacy DB file.
+ let legacy_dir = tmp.join("legacy");
+ fs::create_dir_all(&legacy_dir).unwrap();
+ let legacy_path = legacy_dir.join("thuki.db");
+ fs::write(&legacy_path, b"legacy-data").unwrap();
+
+ // Target path where the DB should be migrated to.
+ let new_dir = tmp.join("new");
+ fs::create_dir_all(&new_dir).unwrap();
+ let new_path = new_dir.join("thuki.db");
+
+ // Manually test the migration logic (we can't call migrate_legacy_db
+ // directly because it hardcodes ~/.thuki, so we test the core logic).
+ assert!(!new_path.exists());
+ if legacy_path.exists() && !new_path.exists() {
+ fs::rename(&legacy_path, &new_path).unwrap();
+ }
+ assert!(new_path.exists());
+ assert!(!legacy_path.exists());
+ assert_eq!(fs::read(&new_path).unwrap(), b"legacy-data");
+
+ fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn migrate_legacy_db_skips_when_target_exists() {
+ let tmp = std::env::temp_dir().join(format!("thuki-migrate-{}", uuid::Uuid::new_v4()));
+ fs::create_dir_all(&tmp).unwrap();
+
+ let new_path = tmp.join("thuki.db");
+ fs::write(&new_path, b"existing-data").unwrap();
+
+ // When the target already exists, migration should be skipped.
+ migrate_legacy_db(&new_path);
+ assert_eq!(fs::read(&new_path).unwrap(), b"existing-data");
+
+ fs::remove_dir_all(&tmp).unwrap();
}
}
diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs
index 9dcd7383..4e734036 100644
--- a/src-tauri/src/history.rs
+++ b/src-tauri/src/history.rs
@@ -10,6 +10,7 @@ use std::sync::Mutex;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
+use tauri::Manager;
use tauri::State;
use crate::commands::{ChatMessage, ConversationHistory, SystemPrompt};
@@ -24,6 +25,7 @@ pub struct SaveMessagePayload {
pub role: String,
pub content: String,
pub quoted_text: Option,
+ pub image_paths: Option>,
}
/// Response returned when saving a conversation.
@@ -66,9 +68,14 @@ pub fn save_conversation(
database::create_conversation(&conn, placeholder_title.as_deref(), &model)
.map_err(|e| e.to_string())?;
- let batch: Vec<(String, String, Option)> = messages
+ let batch: Vec<(String, String, Option, Option)> = messages
.into_iter()
- .map(|m| (m.role, m.content, m.quoted_text))
+ .map(|m| {
+ let image_json = m.image_paths.filter(|v| !v.is_empty()).map(|v| {
+ serde_json::to_string(&v).expect("Vec serialization is infallible")
+ });
+ (m.role, m.content, m.quoted_text, image_json)
+ })
.collect();
database::insert_messages_batch(&conn, &conversation_id, &batch).map_err(|e| e.to_string())?;
@@ -84,15 +91,20 @@ pub fn persist_message(
role: String,
content: String,
quoted_text: Option,
+ image_paths: Option>,
db: State<'_, Database>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
+ let image_json = image_paths
+ .filter(|v| !v.is_empty())
+ .map(|v| serde_json::to_string(&v).expect("Vec serialization is infallible"));
database::insert_message(
&conn,
&conversation_id,
&role,
&content,
quoted_text.as_deref(),
+ image_json.as_deref(),
)
.map_err(|e| e.to_string())?;
Ok(())
@@ -134,18 +146,45 @@ pub fn load_conversation(
conv.push(ChatMessage {
role: msg.role.clone(),
content: msg.content.clone(),
+ images: None,
});
}
Ok(persisted)
}
-/// Deletes a conversation and all its messages from SQLite.
+/// Deletes a conversation and all its messages from SQLite, and immediately
+/// removes any image files referenced by those messages from disk.
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg_attr(not(coverage), tauri::command)]
-pub fn delete_conversation(conversation_id: String, db: State<'_, Database>) -> Result<(), String> {
+pub fn delete_conversation(
+ app_handle: tauri::AppHandle,
+ conversation_id: String,
+ db: State<'_, Database>,
+) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
- database::delete_conversation(&conn, &conversation_id).map_err(|e| e.to_string())
+
+ // Collect image paths before deleting messages (CASCADE will remove them).
+ let messages = database::load_messages(&conn, &conversation_id).map_err(|e| e.to_string())?;
+ let image_paths: Vec = messages
+ .iter()
+ .filter_map(|m| m.image_paths.as_ref())
+ .filter_map(|json| serde_json::from_str::>(json).ok())
+ .flatten()
+ .collect();
+
+ database::delete_conversation(&conn, &conversation_id).map_err(|e| e.to_string())?;
+
+ // Best-effort file cleanup — don't fail the command if a file is missing.
+ let base_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("failed to resolve app data dir: {e}"))?;
+ for path in &image_paths {
+ let _ = crate::images::remove_image(&base_dir, path);
+ }
+
+ Ok(())
}
/// Generates a short AI title for a saved conversation by asking Ollama.
@@ -184,10 +223,12 @@ pub async fn generate_title(
ChatMessage {
role: "system".to_string(),
content: system_prompt.0.clone(),
+ images: None,
},
ChatMessage {
role: "user".to_string(),
content: title_prompt,
+ images: None,
},
];
@@ -246,11 +287,13 @@ mod tests {
role: "user".to_string(),
content: "What is Rust?".to_string(),
quoted_text: None,
+ image_paths: Some(vec!["/tmp/img.jpg".to_string()]),
},
SaveMessagePayload {
role: "assistant".to_string(),
content: "Rust is a systems programming language.".to_string(),
quoted_text: None,
+ image_paths: None,
},
];
@@ -261,12 +304,17 @@ mod tests {
.map(|m| m.content.trim().to_string());
let conversation_id =
- database::create_conversation(&conn, placeholder_title.as_deref(), "llama3.2:3b")
+ database::create_conversation(&conn, placeholder_title.as_deref(), "gemma3:4b")
.unwrap();
- let batch: Vec<(String, String, Option)> = messages
+ let batch: Vec<(String, String, Option, Option)> = messages
.into_iter()
- .map(|m| (m.role, m.content, m.quoted_text))
+ .map(|m| {
+ let image_json = m.image_paths.filter(|v| !v.is_empty()).map(|v| {
+ serde_json::to_string(&v).expect("Vec serialization is infallible")
+ });
+ (m.role, m.content, m.quoted_text, image_json)
+ })
.collect();
database::insert_messages_batch(&conn, &conversation_id, &batch).unwrap();
@@ -276,7 +324,12 @@ mod tests {
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].role, "user");
assert_eq!(loaded[0].content, "What is Rust?");
+ assert_eq!(
+ loaded[0].image_paths.as_deref(),
+ Some(r#"["/tmp/img.jpg"]"#)
+ );
assert_eq!(loaded[1].role, "assistant");
+ assert!(loaded[1].image_paths.is_none());
}
#[test]
diff --git a/src-tauri/src/images.rs b/src-tauri/src/images.rs
new file mode 100644
index 00000000..684b486b
--- /dev/null
+++ b/src-tauri/src/images.rs
@@ -0,0 +1,483 @@
+/*!
+ * Image storage and lifecycle management.
+ *
+ * Images are stored in a flat directory at `/images/` with
+ * UUID-based filenames. This follows the industry-standard pattern used by
+ * Signal, iMessage, and Slack — media files are independent entities linked
+ * to messages through path references, not organized by conversation.
+ *
+ * Each image is compressed to JPEG (quality 85, max 1920px) on save to keep
+ * disk usage and Ollama inference latency low.
+ *
+ * Lifecycle:
+ * - **Paste/drop:** frontend sends raw bytes → `save_image` compresses and
+ * writes to the flat images directory, returns the file path.
+ * - **Remove:** user clicks "X" on a thumbnail → `remove_image` deletes the
+ * file from disk.
+ * - **Cleanup:** `cleanup_orphaned_images` removes files not referenced by
+ * any saved message. Runs on startup and periodically.
+ */
+
+use std::path::{Path, PathBuf};
+
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+use image::imageops::FilterType;
+use tauri::Manager;
+
+/// Maximum dimension (width or height) for saved images. Images exceeding this
+/// are downscaled proportionally, preserving aspect ratio.
+const MAX_DIMENSION: u32 = 1920;
+
+/// JPEG compression quality (1–100). 85 balances file size and visual fidelity
+/// for vision model consumption.
+const JPEG_QUALITY: u8 = 85;
+
+/// Maximum number of images allowed per message.
+pub const MAX_IMAGES_PER_MESSAGE: usize = 3;
+
+/// Resolves the root images directory: `/images/`.
+pub fn images_root(base_dir: &Path) -> PathBuf {
+ base_dir.join("images")
+}
+
+/// Returns a closure that formats an error with a contextual message prefix.
+/// One shared generic instantiation instead of N separate closure functions
+/// in the llvm-cov function table.
+fn err(context: &'static str) -> impl FnOnce(E) -> String {
+ move |e| format!("{context}: {e}")
+}
+
+/// Compresses raw image bytes to JPEG (max 1920px) and writes to the flat
+/// images directory with a UUID filename.
+///
+/// Returns the absolute path of the saved file. The caller owns the path and
+/// can pass it to the frontend for `asset://` rendering.
+///
+/// # Errors
+///
+/// Returns an error if the image bytes cannot be decoded, the output directory
+/// cannot be created, or the file cannot be written.
+pub fn save_image(base_dir: &Path, image_data: &[u8]) -> Result {
+ let img = image::load_from_memory(image_data).map_err(err("failed to decode image"))?;
+
+ let resized = if img.width() > MAX_DIMENSION || img.height() > MAX_DIMENSION {
+ img.resize(MAX_DIMENSION, MAX_DIMENSION, FilterType::Lanczos3)
+ } else {
+ img
+ };
+
+ let dir = images_root(base_dir);
+ std::fs::create_dir_all(&dir).map_err(err("failed to create image directory"))?;
+
+ let filename = format!("{}.jpg", uuid::Uuid::new_v4());
+ let path = dir.join(&filename);
+
+ let rgb = resized.to_rgb8();
+ let mut jpeg_buf = Vec::new();
+ {
+ let mut encoder =
+ image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_buf, JPEG_QUALITY);
+ encoder
+ .encode_image(&rgb)
+ .map_err(err("failed to encode JPEG"))?;
+ }
+
+ std::fs::write(&path, &jpeg_buf).map_err(err("failed to write image file"))?;
+
+ path.to_str()
+ .map(|s| s.to_string())
+ .ok_or("image path contains non-UTF-8 characters".to_string())
+}
+
+/// Deletes a single image file from disk, provided it resides within the
+/// given `base_dir/images/` directory. Rejects paths outside the images root
+/// to prevent path-traversal attacks via the IPC boundary.
+///
+/// # Errors
+///
+/// Returns an error if the path escapes the images directory or the file
+/// cannot be removed. Silently succeeds if the file does not exist (idempotent).
+pub fn remove_image(base_dir: &Path, path: &str) -> Result<(), String> {
+ let p = Path::new(path);
+ if !p.exists() {
+ return Ok(());
+ }
+ let canonical = p
+ .canonicalize()
+ .map_err(err("failed to resolve image path"))?;
+ let root = images_root(base_dir)
+ .canonicalize()
+ .map_err(err("failed to resolve images root"))?;
+ if !canonical.starts_with(&root) {
+ return Err("path is outside the images directory".to_string());
+ }
+ std::fs::remove_file(p).map_err(err("failed to remove image"))?;
+ Ok(())
+}
+
+/// Removes image files that are not in the set of referenced paths.
+///
+/// `referenced_paths` contains the absolute paths of all images currently
+/// referenced by saved messages. Any file in `/images/` not in
+/// this set is deleted.
+///
+/// # Errors
+///
+/// Returns an error if the images root directory cannot be read.
+pub fn cleanup_orphaned_images(
+ base_dir: &Path,
+ referenced_paths: &[String],
+) -> Result {
+ let root = images_root(base_dir);
+ if !root.exists() {
+ return Ok(0);
+ }
+
+ let entries = std::fs::read_dir(&root).map_err(err("failed to read images directory"))?;
+
+ let mut removed = 0;
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+ let path_str = path.to_string_lossy().to_string();
+ if !referenced_paths.contains(&path_str) && std::fs::remove_file(&path).is_ok() {
+ removed += 1;
+ }
+ }
+
+ Ok(removed)
+}
+
+/// Reads image files from disk and returns their base64-encoded contents
+/// for inclusion in Ollama API requests.
+///
+/// # Errors
+///
+/// Returns an error if any file cannot be read.
+pub fn encode_images_as_base64(paths: &[String]) -> Result, String> {
+ paths
+ .iter()
+ .map(|p| {
+ let bytes = std::fs::read(p).map_err(|e| format!("failed to read image {p}: {e}"))?;
+ Ok(BASE64.encode(&bytes))
+ })
+ .collect()
+}
+
+// ─── Tauri commands ────────────────────────────────────────────────────────
+//
+// Thin wrappers that delegate to the pure functions above. Excluded from
+// coverage builds entirely (`#[cfg(not(coverage))]`) because `coverage(off)`
+// suppresses instrumentation but llvm-cov still counts excluded function
+// signatures as "missed lines" in the summary — breaking the 100% gate.
+
+/// Compresses and saves an image to the flat images directory.
+///
+/// Accepts base64-encoded image data as a string to avoid the performance
+/// penalty of JSON-serializing a `Vec` (millions of individual numbers)
+/// over the Tauri IPC bridge.
+///
+/// The command is `async` so Tauri runs it off the main thread. The heavy
+/// work (base64 decode → image decode → Lanczos3 resize → JPEG encode) is
+/// dispatched to `spawn_blocking` to avoid blocking the async runtime,
+/// keeping the WebView UI fully responsive during processing.
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg_attr(not(coverage), tauri::command)]
+pub async fn save_image_command(
+ app_handle: tauri::AppHandle,
+ image_data_base64: String,
+) -> Result {
+ let base_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("failed to resolve app data dir: {e}"))?;
+
+ tokio::task::spawn_blocking(move || {
+ let image_data = BASE64
+ .decode(&image_data_base64)
+ .map_err(|e| format!("failed to decode base64: {e}"))?;
+ save_image(&base_dir, &image_data)
+ })
+ .await
+ .map_err(|e| format!("image processing task failed: {e}"))?
+}
+
+/// Deletes a single image file from disk (with path containment check).
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg_attr(not(coverage), tauri::command)]
+pub fn remove_image_command(app_handle: tauri::AppHandle, path: String) -> Result<(), String> {
+ let base_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("failed to resolve app data dir: {e}"))?;
+ remove_image(&base_dir, &path)
+}
+
+/// Removes image files not referenced by any saved message.
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg_attr(not(coverage), tauri::command)]
+pub fn cleanup_orphaned_images_command(
+ app_handle: tauri::AppHandle,
+ referenced_paths: Vec,
+) -> Result {
+ let base_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("failed to resolve app data dir: {e}"))?;
+ cleanup_orphaned_images(&base_dir, &referenced_paths)
+}
+
+// ─── Tests ─────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ /// Creates a minimal valid 1x1 red PNG for testing.
+ fn tiny_png() -> Vec {
+ let mut buf = Vec::new();
+ let img = image::RgbImage::from_pixel(1, 1, image::Rgb([255, 0, 0]));
+ let dyn_img = image::DynamicImage::ImageRgb8(img);
+ let mut cursor = std::io::Cursor::new(&mut buf);
+ dyn_img
+ .write_to(&mut cursor, image::ImageFormat::Png)
+ .unwrap();
+ buf
+ }
+
+ /// Creates a large PNG (2000x1500) that exceeds MAX_DIMENSION.
+ fn large_png() -> Vec {
+ let mut buf = Vec::new();
+ let img = image::RgbImage::from_pixel(2000, 1500, image::Rgb([0, 128, 255]));
+ let dyn_img = image::DynamicImage::ImageRgb8(img);
+ let mut cursor = std::io::Cursor::new(&mut buf);
+ dyn_img
+ .write_to(&mut cursor, image::ImageFormat::Png)
+ .unwrap();
+ buf
+ }
+
+ fn temp_dir() -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("thuki-test-{}", uuid::Uuid::new_v4()));
+ fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ #[test]
+ fn save_image_creates_jpeg_file() {
+ let base = temp_dir();
+ let path = save_image(&base, &tiny_png()).unwrap();
+
+ assert!(Path::new(&path).exists());
+ assert!(path.ends_with(".jpg"));
+ // File should be in the flat images/ directory, not a subdirectory.
+ assert!(path.contains("/images/"));
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn save_image_compresses_large_image() {
+ let base = temp_dir();
+ let path = save_image(&base, &large_png()).unwrap();
+
+ let saved = image::open(&path).unwrap();
+ assert!(saved.width() <= MAX_DIMENSION);
+ assert!(saved.height() <= MAX_DIMENSION);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn save_image_rejects_invalid_bytes() {
+ let base = temp_dir();
+ let result = save_image(&base, b"not an image");
+ assert!(result.is_err());
+ assert!(result.unwrap_err().contains("failed to decode image"));
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn save_image_preserves_aspect_ratio() {
+ let base = temp_dir();
+ let mut buf = Vec::new();
+ let img = image::RgbImage::from_pixel(3000, 1000, image::Rgb([0, 0, 0]));
+ let dyn_img = image::DynamicImage::ImageRgb8(img);
+ let mut cursor = std::io::Cursor::new(&mut buf);
+ dyn_img
+ .write_to(&mut cursor, image::ImageFormat::Png)
+ .unwrap();
+
+ let path = save_image(&base, &buf).unwrap();
+ let saved = image::open(&path).unwrap();
+
+ assert_eq!(saved.width(), MAX_DIMENSION);
+ assert!(saved.height() < MAX_DIMENSION);
+ assert_eq!(saved.height(), 640);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn save_image_does_not_upscale_small_images() {
+ let base = temp_dir();
+ let path = save_image(&base, &tiny_png()).unwrap();
+ let saved = image::open(&path).unwrap();
+
+ assert_eq!(saved.width(), 1);
+ assert_eq!(saved.height(), 1);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn remove_image_deletes_file() {
+ let base = temp_dir();
+ let path = save_image(&base, &tiny_png()).unwrap();
+ assert!(Path::new(&path).exists());
+
+ remove_image(&base, &path).unwrap();
+ assert!(!Path::new(&path).exists());
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn remove_image_idempotent_on_missing_file() {
+ let base = temp_dir();
+ let result = remove_image(&base, "/tmp/nonexistent-thuki-image.jpg");
+ assert!(result.is_ok());
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn remove_image_rejects_path_outside_images_dir() {
+ let base = temp_dir();
+ fs::create_dir_all(images_root(&base)).unwrap();
+ let outside = base.join("secret.txt");
+ fs::write(&outside, b"sensitive").unwrap();
+
+ let result = remove_image(&base, outside.to_str().unwrap());
+ assert!(result.is_err());
+ assert!(result.unwrap_err().contains("outside the images directory"));
+ // File must still exist — not deleted.
+ assert!(outside.exists());
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn cleanup_orphaned_images_removes_unreferenced_files() {
+ let base = temp_dir();
+ let kept = save_image(&base, &tiny_png()).unwrap();
+ let orphan = save_image(&base, &tiny_png()).unwrap();
+
+ let referenced = vec![kept.clone()];
+ let removed = cleanup_orphaned_images(&base, &referenced).unwrap();
+
+ assert_eq!(removed, 1);
+ assert!(Path::new(&kept).exists());
+ assert!(!Path::new(&orphan).exists());
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn cleanup_orphaned_images_noop_when_no_images_dir() {
+ let base = temp_dir();
+ let removed = cleanup_orphaned_images(&base, &[]).unwrap();
+ assert_eq!(removed, 0);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn cleanup_orphaned_images_removes_all_when_no_references() {
+ let base = temp_dir();
+ save_image(&base, &tiny_png()).unwrap();
+ save_image(&base, &tiny_png()).unwrap();
+
+ let removed = cleanup_orphaned_images(&base, &[]).unwrap();
+ assert_eq!(removed, 2);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn cleanup_orphaned_images_preserves_all_when_all_referenced() {
+ let base = temp_dir();
+ let p1 = save_image(&base, &tiny_png()).unwrap();
+ let p2 = save_image(&base, &tiny_png()).unwrap();
+
+ let referenced = vec![p1, p2];
+ let removed = cleanup_orphaned_images(&base, &referenced).unwrap();
+ assert_eq!(removed, 0);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn cleanup_orphaned_images_skips_subdirectories() {
+ let base = temp_dir();
+ save_image(&base, &tiny_png()).unwrap();
+ // Create a subdirectory that should be skipped (not a file).
+ fs::create_dir_all(images_root(&base).join("stray-dir")).unwrap();
+
+ let removed = cleanup_orphaned_images(&base, &[]).unwrap();
+ // Only the file should be removed, not the directory.
+ assert_eq!(removed, 1);
+ assert!(images_root(&base).join("stray-dir").exists());
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn encode_images_as_base64_roundtrip() {
+ let base = temp_dir();
+ let path = save_image(&base, &tiny_png()).unwrap();
+
+ let encoded = encode_images_as_base64(&[path.clone()]).unwrap();
+ assert_eq!(encoded.len(), 1);
+
+ let decoded = BASE64.decode(&encoded[0]).unwrap();
+ assert!(!decoded.is_empty());
+ // JPEG magic bytes: FF D8.
+ assert_eq!(decoded[0], 0xFF);
+ assert_eq!(decoded[1], 0xD8);
+
+ fs::remove_dir_all(&base).unwrap();
+ }
+
+ #[test]
+ fn encode_images_as_base64_empty_list() {
+ let result = encode_images_as_base64(&[]).unwrap();
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn encode_images_as_base64_rejects_missing_file() {
+ let result = encode_images_as_base64(&["/tmp/nonexistent-thuki.jpg".to_string()]);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn images_root_resolves_correctly() {
+ let base = Path::new("/tmp/thuki-test");
+ assert_eq!(images_root(base), PathBuf::from("/tmp/thuki-test/images"));
+ }
+
+ #[test]
+ fn max_images_per_message_is_three() {
+ assert_eq!(MAX_IMAGES_PER_MESSAGE, 3);
+ }
+
+ #[test]
+ fn err_helper_formats_context_and_cause() {
+ let format_fn = err("failed to frobnicate");
+ assert_eq!(format_fn("disk full"), "failed to frobnicate: disk full");
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 71131de9..b05d2d48 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -18,6 +18,7 @@
pub mod commands;
pub mod database;
pub mod history;
+pub mod images;
#[cfg(target_os = "macos")]
mod activator;
@@ -394,6 +395,46 @@ fn init_panel(app_handle: &tauri::AppHandle) {
panel.set_has_shadow(false);
}
+// ─── Image cleanup ──────────────────────────────────────────────────────────
+
+/// Interval between periodic orphaned-image cleanup sweeps.
+const IMAGE_CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
+
+/// Runs a single orphaned-image cleanup sweep. Thin orchestration wrapper
+/// that delegates to `database::get_all_image_paths` and
+/// `images::cleanup_orphaned_images`, both independently tested.
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn run_image_cleanup(app_handle: &tauri::AppHandle) {
+ let db = app_handle.state::();
+ let conn = match db.0.lock() {
+ Ok(c) => c,
+ Err(_) => return,
+ };
+ let referenced = database::get_all_image_paths(&conn).unwrap_or_default();
+ drop(conn);
+
+ let base_dir = match app_handle.path().app_data_dir() {
+ Ok(d) => d,
+ Err(_) => return,
+ };
+ let _ = images::cleanup_orphaned_images(&base_dir, &referenced);
+}
+
+/// Spawns a background Tokio task that runs the cleanup sweep on a fixed
+/// interval. Thin async wrapper — delegates to `run_image_cleanup`.
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn spawn_periodic_image_cleanup(app_handle: tauri::AppHandle) {
+ tauri::async_runtime::spawn(async move {
+ let mut interval = tokio::time::interval(IMAGE_CLEANUP_INTERVAL);
+ // Skip the first tick (startup cleanup already ran synchronously).
+ interval.tick().await;
+ loop {
+ interval.tick().await;
+ run_image_cleanup(&app_handle);
+ }
+ });
+}
+
// ─── Application entry point ─────────────────────────────────────────────────
/// Initialises and runs the Tauri application.
@@ -493,10 +534,18 @@ pub fn run() {
app.manage(commands::SystemPrompt(commands::load_system_prompt()));
// ── SQLite database for conversation history ──────────
- let db_conn = database::open_database()
- .expect("failed to initialise SQLite database at ~/.thuki/thuki.db");
+ let app_data_dir = app
+ .path()
+ .app_data_dir()
+ .expect("failed to resolve app data directory");
+ let db_conn = database::open_database(&app_data_dir)
+ .expect("failed to initialise SQLite database");
app.manage(history::Database(std::sync::Mutex::new(db_conn)));
+ // ── Orphaned image cleanup (startup + periodic) ─────────
+ run_image_cleanup(app.handle());
+ spawn_periodic_image_cleanup(app.handle().clone());
+
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -518,6 +567,12 @@ pub fn run() {
history::delete_conversation,
#[cfg(not(coverage))]
history::generate_title,
+ #[cfg(not(coverage))]
+ images::save_image_command,
+ #[cfg(not(coverage))]
+ images::remove_image_command,
+ #[cfg(not(coverage))]
+ images::cleanup_orphaned_images_command,
notify_overlay_hidden,
set_window_frame
])
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 569b7595..b22e5556 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -25,7 +25,11 @@
}
],
"security": {
- "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';",
+ "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost;",
+ "assetProtocol": {
+ "enable": true,
+ "scope": ["$APPDATA/images/**"]
+ },
"capabilities": ["default"]
},
"macOSPrivateApi": true
diff --git a/src/App.tsx b/src/App.tsx
index bc234268..81db8760 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,19 +2,22 @@ import { motion, AnimatePresence } from 'framer-motion';
import type React from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { listen } from '@tauri-apps/api/event';
-import { invoke } from '@tauri-apps/api/core';
+import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { LogicalSize } from '@tauri-apps/api/dpi';
import { useOllama } from './hooks/useOllama';
+import type { Message } from './hooks/useOllama';
import { useConversationHistory } from './hooks/useConversationHistory';
import { ConversationView } from './view/ConversationView';
import { AskBarView } from './view/AskBarView';
import { HistoryPanel } from './components/HistoryPanel';
+import { ImagePreviewModal } from './components/ImagePreviewModal';
+import type { AttachedImage } from './types/image';
import { quote } from './config';
import './App.css';
/** Ollama model used for this session — must match the Rust DEFAULT_MODEL_NAME. */
-const MODEL_NAME = 'llama3.2:3b';
+const MODEL_NAME = 'gemma3:4b';
const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility';
@@ -116,6 +119,28 @@ function App() {
const inputRef = useRef(null);
+ /** Images attached to the current (unsent) message. Blob URLs render
+ * immediately; file paths are set asynchronously after Rust processing. */
+ const [attachedImages, setAttachedImages] = useState([]);
+ /** URL of the image currently open in the preview modal (blob or asset URL). */
+ const [previewImageUrl, setPreviewImageUrl] = useState(null);
+
+ /** When the user submits while images are still processing, the submit
+ * intent is stored here. The effect below watches `attachedImages` and
+ * fires the actual `ask()` once every image has a resolved `filePath`. */
+ const pendingSubmitRef = useRef<{
+ query: string;
+ context: string | undefined;
+ } | null>(null);
+ /** True while waiting for images to finish processing before a deferred
+ * submit. Drives the "waiting" UI state in the ask bar. */
+ const [isSubmitPending, setIsSubmitPending] = useState(false);
+ /** User message shown in the chat while waiting for images to finish
+ * processing. Cleared when `ask()` fires and adds the real message. */
+ const [pendingUserMessage, setPendingUserMessage] = useState(
+ null,
+ );
+
/**
* Session counter — incremented on each overlay open. Used in the motion
* key to force AnimatePresence to fully unmount the stale tree before
@@ -136,7 +161,7 @@ function App() {
* chat window state with message bubbles. Transitions from input-bar mode
* to chat-window mode are animated via Framer Motion `layout` prop.
*/
- const isChatMode = messages.length > 0 || isGenerating;
+ const isChatMode = messages.length > 0 || isGenerating || isSubmitPending;
/**
* The bookmark save button is active once the AI has produced at least one
@@ -310,6 +335,14 @@ function App() {
setQuery('');
setSelectedContext(context);
setIsHistoryOpen(false);
+ setAttachedImages((prev) => {
+ for (const img of prev) URL.revokeObjectURL(img.blobUrl);
+ return [];
+ });
+ pendingSubmitRef.current = null;
+ setIsSubmitPending(false);
+ setPendingUserMessage(null);
+
reset();
resetHistory();
setOverlayState('visible');
@@ -330,6 +363,11 @@ function App() {
}
/* v8 ignore stop */
setSelectedContext(null);
+ setPreviewImageUrl(null);
+ setAttachedImages((prev) => {
+ for (const img of prev) URL.revokeObjectURL(img.blobUrl);
+ return [];
+ });
setOverlayState((currentState) => {
if (currentState === 'hidden' || currentState === 'hiding') {
return currentState;
@@ -370,9 +408,13 @@ function App() {
}, [isChatMode, isHistoryOpen]);
// Clear any pending new-conversation confirmation whenever the panel closes.
- useEffect(() => {
- if (!isHistoryOpen) setPendingNewConversation(false);
- }, [isHistoryOpen]);
+ // Uses a ref-based approach to avoid the @eslint-react/set-state-in-effect
+ // warning from calling setState synchronously inside an effect body.
+ const prevHistoryOpenRef = useRef(isHistoryOpen);
+ if (prevHistoryOpenRef.current && !isHistoryOpen) {
+ setPendingNewConversation(false);
+ }
+ prevHistoryOpenRef.current = isHistoryOpen;
/**
* Observes the dropdown's height while it's open and mutates the morphing
@@ -518,6 +560,13 @@ function App() {
resetHistory();
setIsHistoryOpen(false);
setQuery('');
+ setAttachedImages((prev) => {
+ for (const img of prev) URL.revokeObjectURL(img.blobUrl);
+ return [];
+ });
+ pendingSubmitRef.current = null;
+ setIsSubmitPending(false);
+ setPendingUserMessage(null);
}, [reset, resetHistory]);
/**
@@ -550,8 +599,109 @@ function App() {
resetForNewConversation();
}, [resetForNewConversation]);
+ /**
+ * Handles newly attached image files. Creates blob URLs immediately for
+ * instant thumbnail rendering, then processes each file in the background
+ * via base64-encoded IPC to the Rust backend.
+ */
+ const handleImagesAttached = useCallback((files: File[]) => {
+ const newImages: AttachedImage[] = files.map((file) => ({
+ id: crypto.randomUUID(),
+ blobUrl: URL.createObjectURL(file),
+ filePath: null,
+ }));
+
+ setAttachedImages((prev) => [...prev, ...newImages]);
+
+ // Defer backend processing to the next frame so React can render the
+ // blob URL thumbnails immediately — keeps the UI responsive while
+ // FileReader + IPC serialisation happen in subsequent event-loop ticks.
+ requestAnimationFrame(() => {
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const imageId = newImages[i].id;
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ // Extract pure base64 from the data URL (strip "data:image/png;base64,").
+ const base64 = (reader.result as string).split(',')[1];
+ invoke('save_image_command', { imageDataBase64: base64 })
+ .then((filePath) => {
+ setAttachedImages((prev) =>
+ prev.map((img) =>
+ img.id === imageId ? { ...img, filePath } : img,
+ ),
+ );
+ })
+ .catch(() => {
+ setAttachedImages((prev) => {
+ for (const img of prev) {
+ if (img.id === imageId) URL.revokeObjectURL(img.blobUrl);
+ }
+ return prev.filter((img) => img.id !== imageId);
+ });
+ });
+ };
+ reader.readAsDataURL(file);
+ }
+ });
+ }, []);
+
+ /** Removes an attached image from state, revokes the blob URL, and
+ * deletes the staged file from disk if processing completed. */
+ const handleImageRemove = useCallback((id: string) => {
+ setAttachedImages((prev) => {
+ const img = prev.find((i) => i.id === id);
+ if (img) {
+ URL.revokeObjectURL(img.blobUrl);
+ if (img.filePath) {
+ void invoke('remove_image_command', { path: img.filePath });
+ }
+ }
+ return prev.filter((i) => i.id !== id);
+ });
+ }, []);
+
+ /** Opens the preview modal for an attached image (identified by ID).
+ * The ID always comes from the thumbnail component which only renders
+ * items present in attachedImages, so the find always succeeds. */
+ const handleAskBarImagePreview = useCallback(
+ (id: string) => {
+ setPreviewImageUrl(attachedImages.find((i) => i.id === id)!.blobUrl);
+ },
+ [attachedImages],
+ );
+
+ /** Opens the preview modal for a chat history image (identified by file path). */
+ const handleChatImagePreview = useCallback((path: string) => {
+ setPreviewImageUrl(path.startsWith('blob:') ? path : convertFileSrc(path));
+ }, []);
+
+ /** Fires the actual ask() call and cleans up attached images + input. */
+ const executeSubmit = useCallback(
+ (submitQuery: string, context: string | undefined) => {
+ const readyPaths = attachedImages
+ .filter((img) => img.filePath !== null)
+ .map((img) => img.filePath as string);
+ const images = readyPaths.length > 0 ? readyPaths : undefined;
+ ask(submitQuery, context, images);
+ setSelectedContext(null);
+ setQuery('');
+ for (const img of attachedImages) {
+ URL.revokeObjectURL(img.blobUrl);
+ }
+ setAttachedImages([]);
+ inputRef.current!.style.height = 'auto';
+ },
+ [ask, attachedImages, setSelectedContext],
+ );
+
const handleSubmit = useCallback(() => {
- if (query.trim().length === 0 || isGenerating) return;
+ if (
+ (query.trim().length === 0 && attachedImages.length === 0) ||
+ isGenerating
+ )
+ return;
// Sanitize externally-sourced context: strip control characters and enforce
// a length cap to limit prompt-injection surface from host-app selections.
// eslint-disable-next-line no-control-regex
@@ -559,14 +709,101 @@ function App() {
const sanitized = selectedContext
?.replace(CONTROL_CHARS, '')
.slice(0, quote.maxContextLength);
- const hasContext = sanitized && sanitized.trim().length > 0;
- ask(query, hasContext ? sanitized : undefined);
- setSelectedContext(null);
+ const context = sanitized?.trim() ? sanitized : undefined;
+
+ // If all images are ready (or there are none), submit immediately.
+ const hasPendingImages = attachedImages.some(
+ (img) => img.filePath === null,
+ );
+ if (!hasPendingImages) {
+ executeSubmit(query, context);
+ return;
+ }
+
+ // Images are still processing — store the intent and wait. The effect
+ // below will fire the actual ask() once every image has resolved.
+ pendingSubmitRef.current = { query, context };
+ setIsSubmitPending(true);
+
+ // Show the user's message immediately in the chat view. Use file paths
+ // for already-processed images (no loading spinner) and blob URLs only
+ // for images still being processed (ChatBubble shows a spinner for blob: URLs).
+ setPendingUserMessage({
+ id: crypto.randomUUID(),
+ role: 'user',
+ content: query,
+ quotedText: context,
+ imagePaths: attachedImages.map((img) => img.filePath ?? img.blobUrl),
+ });
+
setQuery('');
- if (inputRef.current) {
- inputRef.current.style.height = 'auto';
+ setSelectedContext(null);
+ inputRef.current!.style.height = 'auto';
+ }, [
+ query,
+ isGenerating,
+ executeSubmit,
+ selectedContext,
+ setSelectedContext,
+ attachedImages,
+ ]);
+
+ // When a pending submit exists and all images finish processing, fire it.
+ // Reads `attachedImages` directly (not via `executeSubmit` closure) to
+ // guarantee the effect always sees the freshest file paths.
+ /* eslint-disable @eslint-react/set-state-in-effect -- intentional: effect
+ reacts to image processing completion and must synchronously transition
+ state (pending → submitted) in the same tick to avoid stale renders. */
+ useEffect(() => {
+ if (!pendingSubmitRef.current) return;
+ if (attachedImages.length === 0) {
+ // All images failed — restore the user's query so their text isn't lost.
+ const { query: savedQuery, context: savedContext } =
+ pendingSubmitRef.current;
+ pendingSubmitRef.current = null;
+ setIsSubmitPending(false);
+ setPendingUserMessage(null);
+ setQuery(savedQuery);
+ setSelectedContext(savedContext ?? null);
+ return;
+ }
+ // Wait until every image has finished backend processing.
+ const allReady = attachedImages.every((img) => img.filePath !== null);
+ if (!allReady) return;
+
+ const { query: pendingQuery, context } = pendingSubmitRef.current;
+ pendingSubmitRef.current = null;
+ setIsSubmitPending(false);
+ // Clear the preview message — ask() will add the real one with file paths.
+ setPendingUserMessage(null);
+
+ const images = attachedImages.map((img) => img.filePath as string);
+ void ask(pendingQuery, context, images);
+ setSelectedContext(null);
+ for (const img of attachedImages) {
+ URL.revokeObjectURL(img.blobUrl);
+ }
+ setAttachedImages([]);
+ }, [attachedImages, ask, setSelectedContext]);
+ /* eslint-enable @eslint-react/set-state-in-effect */
+
+ /** Unified cancel handler: reverts a pending submit (undo-send) or cancels
+ * an active Ollama generation. When reverting, restores the user's query
+ * and keeps attached images so they can re-submit or edit. */
+ const handleCancel = useCallback(() => {
+ if (isSubmitPending && pendingSubmitRef.current) {
+ // Undo send — restore input state to before the user hit Enter.
+ setQuery(pendingSubmitRef.current.query);
+ setSelectedContext(pendingSubmitRef.current.context ?? null);
+ pendingSubmitRef.current = null;
+ setIsSubmitPending(false);
+ setPendingUserMessage(null);
+ // Re-focus the textarea so the user can immediately edit.
+ requestAnimationFrame(() => inputRef.current?.focus());
+ return;
}
- }, [query, isGenerating, ask, selectedContext, setSelectedContext]);
+ cancel();
+ }, [isSubmitPending, cancel, setSelectedContext]);
/**
* Synchronizes the React animation state with Tauri-driven overlay visibility
@@ -742,9 +979,13 @@ function App() {
{isChatMode ? (
) : null}
@@ -800,11 +1042,16 @@ function App() {
setQuery={setQuery}
isChatMode={isChatMode}
isGenerating={isGenerating}
+ isSubmitPending={isSubmitPending}
onSubmit={handleSubmit}
- onCancel={cancel}
+ onCancel={handleCancel}
inputRef={inputRef}
selectedText={selectedContext ?? undefined}
onHistoryOpen={handleHistoryToggle}
+ attachedImages={isSubmitPending ? [] : attachedImages}
+ onImagesAttached={handleImagesAttached}
+ onImageRemove={handleImageRemove}
+ onImagePreview={handleAskBarImagePreview}
/>
@@ -844,6 +1091,10 @@ function App() {
) : null}
+ setPreviewImageUrl(null)}
+ />
);
}
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
index ea11251f..d386200f 100644
--- a/src/__tests__/App.test.tsx
+++ b/src/__tests__/App.test.tsx
@@ -1038,6 +1038,68 @@ describe('App', () => {
).toBeInTheDocument();
});
+ it('handleNewConversation revokes blob URLs when images are attached', async () => {
+ enableChannelCaptureWithResponses({
+ list_conversations: [],
+ save_image_command: '/tmp/img.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Get into chat mode with an unsaved turn
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'question' } });
+ });
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+ await act(async () => {});
+ act(() => {
+ getLastChannel()?.simulateMessage({ type: 'Token', data: 'answer' });
+ getLastChannel()?.simulateMessage({ type: 'Done' });
+ });
+
+ // Paste an image while in chat mode (unsaved conversation)
+ const replyInput = screen.getByPlaceholderText('Reply...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(replyInput, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ const revokeSpy = vi.mocked(URL.revokeObjectURL);
+ revokeSpy.mockClear();
+
+ // Click + → SwitchConfirmation (unsaved conversation)
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: 'New conversation' }),
+ );
+ });
+
+ // Click "Start New" → resetForNewConversation revokes blob URLs
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Start New' }));
+ });
+
+ expect(revokeSpy).toHaveBeenCalled();
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
it('handleNewConversation saves then resets on Save & Start New', async () => {
enableChannelCaptureWithResponses({
list_conversations: [],
@@ -1151,7 +1213,7 @@ describe('App', () => {
{
id: 'conv-other2',
title: 'Other chat',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 2,
},
@@ -1208,7 +1270,7 @@ describe('App', () => {
{
id: 'c2',
title: 'Other chat',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 1,
},
@@ -1279,7 +1341,7 @@ describe('App', () => {
{
id: 'conv-other',
title: 'Switch target',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 2,
},
@@ -1356,7 +1418,7 @@ describe('App', () => {
{
id: 'conv-target',
title: 'My chat',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 2,
},
@@ -1500,7 +1562,7 @@ describe('App', () => {
{
id: 'conv-active',
title: 'Active chat',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 2,
},
@@ -1562,7 +1624,7 @@ describe('App', () => {
{
id: 'c1',
title: 'Chat',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 1,
},
@@ -1596,7 +1658,7 @@ describe('App', () => {
{
id: 'conv-unrelated',
title: 'Unrelated',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: 1,
message_count: 2,
},
@@ -1625,6 +1687,926 @@ describe('App', () => {
});
});
+ // ─── Image integration ─────────────────────────────────────────────────────
+
+ describe('image integration', () => {
+ /** Helper: paste an image file into the textarea and wait for thumbnails. */
+ async function pasteImage() {
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['fake-img-data'], 'photo.png', {
+ type: 'image/png',
+ });
+ const clipboardData = {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ };
+ await act(async () => {
+ fireEvent.paste(textarea, { clipboardData });
+ });
+ // Thumbnails appear immediately via blob URL (before backend completes)
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+ }
+
+ it('handleImagesAttached stages images and shows thumbnails', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Wait for FileReader + invoke to complete in background
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.objectContaining({
+ imageDataBase64: expect.any(String),
+ }),
+ );
+ });
+ });
+
+ // Thumbnails should still be present
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('handleImageRemove removes thumbnail and calls remove_image_command', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Wait for backend to resolve (filePath set)
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.anything(),
+ );
+ });
+ });
+
+ invoke.mockClear();
+
+ // Click remove button on the thumbnail
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }));
+ });
+
+ expect(invoke).toHaveBeenCalledWith('remove_image_command', {
+ path: '/tmp/staged/img1.jpg',
+ });
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ it('handleSubmit with images passes imagePaths and clears attachedImages', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Wait for backend to resolve (filePath set)
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.anything(),
+ );
+ });
+ });
+
+ // Type a message and submit
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'describe this' } });
+ });
+
+ invoke.mockClear();
+ enableChannelCapture();
+
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+ await act(async () => {});
+
+ // ask_ollama should be called with imagePaths
+ expect(invoke).toHaveBeenCalledWith(
+ 'ask_ollama',
+ expect.objectContaining({
+ message: 'describe this',
+ imagePaths: ['/tmp/staged/img1.jpg'],
+ }),
+ );
+ });
+
+ it('submits with images and no text', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Wait for backend to resolve
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.anything(),
+ );
+ });
+ });
+
+ invoke.mockClear();
+ enableChannelCapture();
+
+ // Submit with Enter (no text, just images)
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+ await act(async () => {});
+
+ // ask_ollama should be called with empty message but imagePaths
+ expect(invoke).toHaveBeenCalledWith(
+ 'ask_ollama',
+ expect.objectContaining({
+ message: '',
+ imagePaths: ['/tmp/staged/img1.jpg'],
+ }),
+ );
+ });
+
+ it('previewImage opens ImagePreviewModal and closing clears it', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Click preview button on thumbnail
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /preview/i }));
+ });
+
+ // ImagePreviewModal should be open (has role="dialog")
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Close the modal
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /close preview/i }));
+ });
+
+ // Dialog should be gone
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('handleImagesAttached removes image when backend fails', async () => {
+ invoke.mockImplementation(async (cmd: string) => {
+ if (cmd === 'save_image_command') throw new Error('disk full');
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.drop(
+ document.querySelector('[class*="flex flex-col w-full shrink-0"]')!,
+ {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file] },
+ },
+ );
+ });
+
+ // Thumbnail appears immediately via blob URL
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ // Wait for FileReader + invoke to settle — failed image gets removed
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.anything(),
+ );
+ });
+ });
+
+ // Image should be removed after backend failure
+ await vi.waitFor(() => {
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+ });
+
+ it('handleImagesAttached skips images that fail to stage', async () => {
+ // First call succeeds, second call fails
+ let saveCallCount = 0;
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // channel capture — no-op for this test
+ }
+ if (cmd === 'save_image_command') {
+ saveCallCount++;
+ if (saveCallCount === 2) throw new Error('disk full');
+ return '/tmp/staged/img1.jpg';
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Drop two image files via the AskBarView wrapper
+ const askBarWrapper = document.querySelector(
+ '[class*="flex flex-col w-full shrink-0"]',
+ );
+ expect(askBarWrapper).not.toBeNull();
+
+ const file1 = new File(['data1'], 'img1.png', { type: 'image/png' });
+ const file2 = new File(['data2'], 'img2.png', { type: 'image/png' });
+ fireEvent.drop(askBarWrapper!, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file1, file2] },
+ });
+
+ // Both thumbnails appear immediately
+ await vi.waitFor(() => {
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
+ });
+
+ // Wait for both backend calls to settle
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(saveCallCount).toBe(2);
+ });
+ });
+
+ // Failed image gets removed, only one remains
+ await vi.waitFor(() => {
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
+ });
+ });
+
+ it('handleChatImagePreview opens modal for chat history image', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Wait for backend to resolve
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.anything(),
+ );
+ });
+ });
+
+ // Type and submit to create a user message with image
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'what is this?' } });
+ });
+
+ invoke.mockClear();
+ enableChannelCapture();
+
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+ await act(async () => {});
+
+ // Simulate AI response completing
+ act(() => {
+ getLastChannel()?.simulateMessage({ type: 'Token', data: 'It is' });
+ getLastChannel()?.simulateMessage({ type: 'Token', data: ' a cat.' });
+ getLastChannel()?.simulateMessage({ type: 'Done' });
+ });
+
+ // The user message should have a thumbnail from chat history (via convertFileSrc)
+ // Find the preview button in the chat bubble (not the ask bar)
+ const previewButtons = screen.getAllByRole('button', {
+ name: /preview/i,
+ });
+ // The chat bubble thumbnail should be present
+ expect(previewButtons.length).toBeGreaterThan(0);
+
+ await act(async () => {
+ fireEvent.click(previewButtons[0]);
+ });
+
+ // ImagePreviewModal should be open
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Close it
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /close preview/i }));
+ });
+
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('handleChatImagePreview passes blob URLs through without convertFileSrc', async () => {
+ // Make save_image_command hang so the image stays as a blob URL
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // channel capture
+ }
+ if (cmd === 'save_image_command') {
+ return new Promise(() => {}); // never resolves
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Paste and submit while still processing → pendingUserMessage with blob URL
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'what is this?' } });
+ });
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+
+ // Pending user message should be visible in chat with a blob URL thumbnail
+ await vi.waitFor(() => {
+ expect(screen.getByText('what is this?')).toBeInTheDocument();
+ });
+
+ // Click the preview button in the chat bubble — should open the modal
+ // with the blob URL directly (no convertFileSrc wrapping).
+ const previewButtons = screen.getAllByRole('button', {
+ name: /preview/i,
+ });
+ expect(previewButtons.length).toBeGreaterThan(0);
+
+ await act(async () => {
+ fireEvent.click(previewButtons[0]);
+ });
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Flush stale FileReader macrotask so it doesn't leak into the next test.
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+ });
+
+ it('handleImageRemove is safe when called twice for the same image', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/staged/img1.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ await pasteImage();
+
+ // Wait for backend to resolve
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith(
+ 'save_image_command',
+ expect.anything(),
+ );
+ });
+ });
+
+ invoke.mockClear();
+
+ // Click remove twice rapidly — the second call should be a no-op
+ // (the functional updater in setAttachedImages will find no matching
+ // image on the second pass, exercising the !img branch).
+ const removeBtn = screen.getByRole('button', { name: /remove/i });
+ await act(async () => {
+ fireEvent.click(removeBtn);
+ fireEvent.click(removeBtn);
+ });
+
+ // remove_image_command should only be called once
+ const removeCalls = invoke.mock.calls.filter(
+ (call) => call[0] === 'remove_image_command',
+ );
+ expect(removeCalls).toHaveLength(1);
+ });
+
+ it('handleImageRemove revokes blob URL without calling remove_image_command when filePath is null', async () => {
+ // Make save_image_command hang forever (never resolve)
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // channel capture — no-op
+ }
+ if (cmd === 'save_image_command') {
+ return new Promise(() => {}); // never resolves
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Paste an image — thumbnail appears immediately with null filePath
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ invoke.mockClear();
+
+ // Remove the image while filePath is still null
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }));
+ });
+
+ // Should NOT call remove_image_command (no file to delete)
+ expect(invoke).not.toHaveBeenCalledWith(
+ 'remove_image_command',
+ expect.anything(),
+ );
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ it('defers submit when images are still processing and fires when ready', async () => {
+ // Flush any stale macrotasks (e.g. FileReader.onload from prior tests)
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ // Track save_image_command calls scoped to THIS test
+ let resolveSave: ((path: string) => void) | null = null;
+ const savePromises: Promise[] = [];
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // Accept channel for ask_ollama
+ }
+ if (cmd === 'save_image_command') {
+ const p = new Promise((resolve) => {
+ resolveSave = resolve;
+ });
+ savePromises.push(p);
+ return p;
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Paste an image — thumbnail appears immediately (filePath null)
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ // Wait for this test's FileReader to complete and call save_image_command
+ await act(async () => {
+ await vi.waitFor(() => expect(savePromises).toHaveLength(1));
+ });
+
+ // Type and submit while image is still processing
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'describe this' } });
+ });
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+
+ // Should show "Processing images" state
+ expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
+
+ // Resolve the image — triggers deferred submit chain
+ resolveSave!('/tmp/staged/img1.jpg');
+
+ // Flush async chain: promise → state update → effect → ask → invoke
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ // User message should appear in the chat (ask() fired the real submit)
+ expect(screen.getByText('describe this')).toBeInTheDocument();
+ });
+
+ it('stop button cancels active generation via handleCancel', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/img.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Start a normal text conversation (no images)
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'hello' } });
+ });
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+ await act(async () => {});
+
+ // Should be generating — stop button visible
+ const stopBtn = screen.getByRole('button', { name: /stop/i });
+ expect(stopBtn).toBeInTheDocument();
+
+ // Click stop — should call cancel_generation
+ invoke.mockClear();
+ enableChannelCapture();
+
+ await act(async () => {
+ fireEvent.click(stopBtn);
+ });
+
+ expect(invoke).toHaveBeenCalledWith('cancel_generation');
+ });
+
+ it('cancelling during pending submit restores input (undo send)', async () => {
+ // Flush stale macrotasks from prior tests
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // Accept channel
+ }
+ if (cmd === 'save_image_command') {
+ return new Promise(() => {}); // never resolves
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Paste an image
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ // Type and submit while image is still processing
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'my question' } });
+ });
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+
+ // Should be in chat mode with stop button
+ expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
+
+ // Click stop to cancel the pending submit
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /stop/i }));
+ });
+
+ // Should revert to ask-bar mode with the query restored
+ const restoredTextarea = screen.getByPlaceholderText(
+ 'Ask Thuki anything...',
+ );
+ expect(restoredTextarea).toBeInTheDocument();
+ expect((restoredTextarea as HTMLTextAreaElement).value).toBe(
+ 'my question',
+ );
+
+ // Images should still be visible (still processing in background)
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+
+ // ask_ollama should never have been called
+ expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything());
+ });
+
+ it('waits for all images before firing deferred submit', async () => {
+ // Flush stale macrotasks from prior tests
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ // Two images: each gets its own resolve function
+ const resolvers: ((path: string) => void)[] = [];
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // Accept channel
+ }
+ if (cmd === 'save_image_command') {
+ return new Promise((resolve) => {
+ resolvers.push(resolve);
+ });
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Drop two images at once
+ const askBarWrapper = document.querySelector(
+ '[class*="flex flex-col w-full shrink-0"]',
+ )!;
+ const file1 = new File(['d1'], 'a.png', { type: 'image/png' });
+ const file2 = new File(['d2'], 'b.png', { type: 'image/png' });
+ fireEvent.drop(askBarWrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file1, file2] },
+ });
+
+ // Wait for both save_image_command calls
+ await act(async () => {
+ await vi.waitFor(() => expect(resolvers).toHaveLength(2));
+ });
+
+ // Submit while both images are still processing
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'two images' } });
+ });
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+
+ expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
+
+ // Resolve ONLY the first image — allReady should still be false
+ await act(async () => {
+ resolvers[0]('/tmp/img1.jpg');
+ });
+ await act(async () => {});
+
+ // Still processing — second image not ready
+ expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
+
+ // Resolve the second image — now allReady is true, submit fires
+ await act(async () => {
+ resolvers[1]('/tmp/img2.jpg');
+ });
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ // User message should appear
+ expect(screen.getByText('two images')).toBeInTheDocument();
+ });
+
+ it('cancels deferred submit when all images fail', async () => {
+ // Make save_image_command hang then reject
+ let rejectSave: ((err: Error) => void) | null = null;
+ invoke.mockImplementation(
+ async (cmd: string, args?: Record) => {
+ if (args && 'onEvent' in args) {
+ // channel capture
+ }
+ if (cmd === 'save_image_command') {
+ return new Promise((_, reject) => {
+ rejectSave = reject;
+ });
+ }
+ },
+ );
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Paste and submit while processing
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ act(() => {
+ fireEvent.change(textarea, { target: { value: 'describe' } });
+ });
+
+ // Wait for FileReader to complete and save_image_command to be invoked
+ // (which sets rejectSave via the promise constructor).
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(rejectSave).not.toBeNull();
+ });
+ });
+
+ act(() => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+
+ // Waiting state
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /stop/i }),
+ ).toBeInTheDocument();
+ });
+
+ // Reject the image — it should be removed and pending submit cancelled
+ await act(async () => {
+ rejectSave!(new Error('disk full'));
+ });
+
+ // Image removed → no thumbnails → pending submit cancelled
+ await vi.waitFor(() => {
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ // ask_ollama should never have been called
+ expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything());
+
+ // The "Processing images" button should be gone — back to normal send
+ expect(
+ screen.getByRole('button', { name: /send message/i }),
+ ).toBeInTheDocument();
+
+ // User's query should be restored so their text isn't lost
+ expect(screen.getByPlaceholderText('Ask Thuki anything...')).toHaveValue(
+ 'describe',
+ );
+ });
+ });
+
+ it('revokes blob URLs when overlay reopens with attached images', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/img.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ // Paste an image so attachedImages is non-empty
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ // Reopen overlay — should clear images and revoke blob URLs
+ await showOverlay();
+
+ expect(URL.revokeObjectURL).toHaveBeenCalled();
+ expect(screen.queryByRole('list', { name: /attached images/i })).toBeNull();
+ });
+
+ it('revokes blob URLs when overlay hides with attached images', async () => {
+ enableChannelCaptureWithResponses({
+ save_image_command: '/tmp/img.jpg',
+ });
+
+ render();
+ await act(async () => {});
+ await showOverlay();
+
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['data'], 'img.png', { type: 'image/png' });
+ await act(async () => {
+ fireEvent.paste(textarea, {
+ clipboardData: {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ },
+ });
+ });
+
+ await vi.waitFor(() => {
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ });
+
+ const revokeSpy = vi.mocked(URL.revokeObjectURL);
+ revokeSpy.mockClear();
+
+ // Hide overlay via Escape — requestHideOverlay should revoke blob URLs
+ await act(async () => {
+ fireEvent.keyDown(window, { key: 'Escape' });
+ });
+
+ expect(revokeSpy).toHaveBeenCalled();
+ });
+
it('resets session on overlay reopen', async () => {
render();
await act(async () => {});
diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx
index 63726d5a..05b1f01d 100644
--- a/src/components/ChatBubble.tsx
+++ b/src/components/ChatBubble.tsx
@@ -1,6 +1,8 @@
import { motion } from 'framer-motion';
import { MarkdownRenderer } from './MarkdownRenderer';
import { CopyButton } from './CopyButton';
+import { ImageThumbnails } from './ImageThumbnails';
+import { convertFileSrc } from '@tauri-apps/api/core';
import { formatQuotedText } from '../utils/formatQuote';
import { quote } from '../config';
@@ -15,6 +17,10 @@ interface ChatBubbleProps {
quotedText?: string;
/** Whether this bubble is actively streaming content from the LLM. */
isStreaming?: boolean;
+ /** Absolute file paths of images attached to this message, if any. */
+ imagePaths?: string[];
+ /** Called when the user clicks a thumbnail to preview it. */
+ onImagePreview?: (path: string) => void;
}
/**
@@ -53,6 +59,8 @@ export function ChatBubble({
index,
quotedText,
isStreaming = false,
+ imagePaths,
+ onImagePreview,
}: ChatBubbleProps) {
const isUser = role === 'user';
@@ -77,11 +85,28 @@ export function ChatBubble({
)}
)}
- {content}
-
-
-
+ {imagePaths && imagePaths.length > 0 && onImagePreview && (
+
+ ({
+ id: p,
+ src: p.startsWith('blob:') ? p : convertFileSrc(p),
+ loading: p.startsWith('blob:'),
+ }))}
+ onPreview={onImagePreview}
+ size={48}
+ />
+
+ )}
+ {content && (
+
{content}
+ )}
+ {content && (
+
+
+
+ )}
) : (
/* AI plain text — full width, no bubble chrome */
diff --git a/src/components/ImagePreviewModal.tsx b/src/components/ImagePreviewModal.tsx
new file mode 100644
index 00000000..4c20ef55
--- /dev/null
+++ b/src/components/ImagePreviewModal.tsx
@@ -0,0 +1,88 @@
+import { motion, AnimatePresence } from 'framer-motion';
+import { useEffect, useCallback } from 'react';
+
+interface ImagePreviewModalProps {
+ /** URL of the image to preview (blob URL or asset URL). Null when closed. */
+ imageUrl: string | null;
+ /** Called when the modal should close. */
+ onClose: () => void;
+}
+
+/**
+ * Full-screen modal overlay that displays an image at its natural size,
+ * scaled to fit the viewport. Closes on backdrop click, close button,
+ * or Escape key.
+ */
+export function ImagePreviewModal({
+ imageUrl,
+ onClose,
+}: ImagePreviewModalProps) {
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ useEffect(() => {
+ if (!imageUrl) return;
+ window.addEventListener('keydown', handleKeyDown, { capture: true });
+ return () =>
+ window.removeEventListener('keydown', handleKeyDown, { capture: true });
+ }, [imageUrl, handleKeyDown]);
+
+ return (
+
+ {imageUrl && (
+
+ e.stopPropagation()}
+ className="max-w-[90%] max-h-[90%] object-contain rounded-lg shadow-2xl"
+ />
+
+
+ )}
+
+ );
+}
diff --git a/src/components/ImageThumbnails.tsx b/src/components/ImageThumbnails.tsx
new file mode 100644
index 00000000..60532fea
--- /dev/null
+++ b/src/components/ImageThumbnails.tsx
@@ -0,0 +1,103 @@
+import { motion, AnimatePresence } from 'framer-motion';
+
+interface ThumbnailItem {
+ /** Unique key for React list rendering. */
+ id: string;
+ /** URL to render (blob URL, asset URL, or any valid image src). */
+ src: string;
+ /** Whether the image is still being processed by the backend. */
+ loading?: boolean;
+}
+
+interface ImageThumbnailsProps {
+ /** Images to display as thumbnails. */
+ items: ThumbnailItem[];
+ /** Called with the item ID when a thumbnail is clicked (opens preview). */
+ onPreview: (id: string) => void;
+ /** Called with the item ID when the remove button is clicked. Omit to hide remove buttons. */
+ onRemove?: (id: string) => void;
+ /** Thumbnail size in pixels. Defaults to 56. */
+ size?: number;
+}
+
+/**
+ * Renders a horizontal row of image thumbnails with optional remove buttons.
+ * Used in the ask bar (with remove) and in chat bubbles (without remove).
+ */
+export function ImageThumbnails({
+ items,
+ onPreview,
+ onRemove,
+ size = 56,
+}: ImageThumbnailsProps) {
+ if (items.length === 0) return null;
+
+ return (
+
+
+ {items.map((item) => (
+
+
+ {onRemove && (
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+export type { ThumbnailItem };
diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx
index 64197fba..fb74225a 100644
--- a/src/components/__tests__/ChatBubble.test.tsx
+++ b/src/components/__tests__/ChatBubble.test.tsx
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { ChatBubble } from '../ChatBubble';
describe('ChatBubble', () => {
@@ -132,6 +132,86 @@ describe('ChatBubble', () => {
});
});
+ describe('Image attachments', () => {
+ it('renders ImageThumbnails when imagePaths and onImagePreview are provided', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
+ });
+
+ it('does not render ImageThumbnails when imagePaths is not provided', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ it('does not render ImageThumbnails when imagePaths is empty', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ it('does not render ImageThumbnails when onImagePreview is not provided', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ it('renders images but no text span when content is empty', () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ // The content span should not be rendered when content is empty
+ expect(container.querySelector('.text-white\\/95')).toBeNull();
+ // CopyButton should also be hidden — nothing to copy
+ expect(screen.queryByRole('button', { name: /copy/i })).toBeNull();
+ });
+ });
+
describe('Layout', () => {
it('has max-width constraint (max-w-[80%])', () => {
const { container } = render(
diff --git a/src/components/__tests__/ConversationItem.test.tsx b/src/components/__tests__/ConversationItem.test.tsx
index f20f6fdf..a29d488a 100644
--- a/src/components/__tests__/ConversationItem.test.tsx
+++ b/src/components/__tests__/ConversationItem.test.tsx
@@ -6,7 +6,7 @@ import type { ConversationSummary } from '../../types/history';
const SUMMARY: ConversationSummary = {
id: 'conv-1',
title: 'How does React work?',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: Date.now(),
message_count: 6,
};
diff --git a/src/components/__tests__/HistoryPanel.test.tsx b/src/components/__tests__/HistoryPanel.test.tsx
index 08fefbd6..94936625 100644
--- a/src/components/__tests__/HistoryPanel.test.tsx
+++ b/src/components/__tests__/HistoryPanel.test.tsx
@@ -11,21 +11,21 @@ const CONVERSATIONS: ConversationSummary[] = [
{
id: 'c1',
title: 'React basics',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: NOW,
message_count: 4,
},
{
id: 'c2',
title: 'Python bug fix',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: YESTERDAY,
message_count: 6,
},
{
id: 'c3',
title: 'Old topic',
- model: 'llama3.2:3b',
+ model: 'gemma3:4b',
updated_at: OLDER,
message_count: 2,
},
diff --git a/src/components/__tests__/ImagePreviewModal.test.tsx b/src/components/__tests__/ImagePreviewModal.test.tsx
new file mode 100644
index 00000000..c09d3f5b
--- /dev/null
+++ b/src/components/__tests__/ImagePreviewModal.test.tsx
@@ -0,0 +1,141 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { ImagePreviewModal } from '../ImagePreviewModal';
+
+describe('ImagePreviewModal', () => {
+ describe('when imageUrl is null', () => {
+ it('renders nothing', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ });
+
+ it('does not register keydown listener when closed', () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when imageUrl is set', () => {
+ const testUrl = 'blob:http://localhost/test-image-uuid';
+
+ it('renders a dialog with correct aria attributes', () => {
+ render();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toBeInTheDocument();
+ expect(dialog).toHaveAttribute('aria-label', 'Image preview');
+ });
+
+ it('renders the image with the provided URL directly', () => {
+ render();
+ const img = screen.getByAltText('Preview');
+ expect(img).toBeInTheDocument();
+ expect(img.getAttribute('src')).toBe(testUrl);
+ });
+
+ it('renders the close button with aria-label', () => {
+ render();
+ expect(
+ screen.getByRole('button', { name: 'Close preview' }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the close icon SVG with aria-hidden', () => {
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector('svg');
+ expect(svg).not.toBeNull();
+ expect(svg!.getAttribute('aria-hidden')).toBe('true');
+ });
+ });
+
+ describe('closing interactions', () => {
+ const testUrl = 'blob:http://localhost/test-image-uuid';
+
+ it('calls onClose when clicking the backdrop', () => {
+ const onClose = vi.fn();
+ render();
+
+ const dialog = screen.getByRole('dialog');
+ fireEvent.click(dialog);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when clicking the close button (also bubbles to backdrop)', () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close preview' }));
+ // The button's onClick fires onClose, and the event bubbles to the
+ // backdrop's onClick which also fires onClose — 2 calls total.
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls onClose on Escape key press', () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onClose on non-Escape key press', () => {
+ const onClose = vi.fn();
+ render();
+
+ fireEvent.keyDown(window, { key: 'Enter' });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('clicking the image does not call onClose (stopPropagation)', () => {
+ const onClose = vi.fn();
+ render();
+
+ const img = screen.getByAltText('Preview');
+ fireEvent.click(img);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('keydown listener lifecycle', () => {
+ it('removes keydown listener when imageUrl changes to null', () => {
+ const onClose = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+
+ // Escape works while open
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ // Close modal
+ rerender();
+
+ // Escape no longer triggers onClose
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes keydown listener on unmount', () => {
+ const onClose = vi.fn();
+ const { unmount } = render(
+ ,
+ );
+
+ unmount();
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/__tests__/ImageThumbnails.test.tsx b/src/components/__tests__/ImageThumbnails.test.tsx
new file mode 100644
index 00000000..6988a12e
--- /dev/null
+++ b/src/components/__tests__/ImageThumbnails.test.tsx
@@ -0,0 +1,177 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { ImageThumbnails } from '../ImageThumbnails';
+import type { ThumbnailItem } from '../ImageThumbnails';
+
+describe('ImageThumbnails', () => {
+ const defaultItems: ThumbnailItem[] = [
+ { id: 'img-1', src: 'blob:http://localhost/img1' },
+ { id: 'img-2', src: 'blob:http://localhost/img2' },
+ ];
+
+ it('returns null when items is empty', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders a list container with correct role and aria-label', () => {
+ render();
+ const list = screen.getByRole('list', { name: 'Attached images' });
+ expect(list).toBeInTheDocument();
+ });
+
+ it('renders one listitem per item', () => {
+ render();
+ const items = screen.getAllByRole('listitem');
+ expect(items).toHaveLength(2);
+ });
+
+ it('renders images with the provided src directly', () => {
+ render();
+ const images = screen.getAllByAltText('Attached');
+ expect(images[0]).toHaveAttribute('src', 'blob:http://localhost/img1');
+ expect(images[1]).toHaveAttribute('src', 'blob:http://localhost/img2');
+ });
+
+ it('applies default size (56px) to images', () => {
+ render();
+ const images = screen.getAllByAltText('Attached');
+ expect(images[0]).toHaveStyle({ width: '56px', height: '56px' });
+ });
+
+ it('applies custom size to images', () => {
+ render(
+ ,
+ );
+ const images = screen.getAllByAltText('Attached');
+ expect(images[0]).toHaveStyle({ width: '80px', height: '80px' });
+ expect(images[1]).toHaveStyle({ width: '80px', height: '80px' });
+ });
+
+ it('calls onPreview with the correct id when thumbnail is clicked', () => {
+ const onPreview = vi.fn();
+ render();
+ const previewButtons = screen.getAllByRole('button', {
+ name: 'Preview image',
+ });
+ fireEvent.click(previewButtons[0]);
+ expect(onPreview).toHaveBeenCalledWith('img-1');
+ fireEvent.click(previewButtons[1]);
+ expect(onPreview).toHaveBeenCalledWith('img-2');
+ expect(onPreview).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not render remove buttons when onRemove is omitted', () => {
+ render();
+ expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
+ });
+
+ it('renders remove buttons when onRemove is provided', () => {
+ render(
+ ,
+ );
+ const removeButtons = screen.getAllByRole('button', {
+ name: 'Remove image',
+ });
+ expect(removeButtons).toHaveLength(2);
+ });
+
+ it('calls onRemove with the correct id when remove button is clicked', () => {
+ const onRemove = vi.fn();
+ render(
+ ,
+ );
+ const removeButtons = screen.getAllByRole('button', {
+ name: 'Remove image',
+ });
+ fireEvent.click(removeButtons[0]);
+ expect(onRemove).toHaveBeenCalledWith('img-1');
+ fireEvent.click(removeButtons[1]);
+ expect(onRemove).toHaveBeenCalledWith('img-2');
+ expect(onRemove).toHaveBeenCalledTimes(2);
+ });
+
+ it('renders the close icon SVG inside remove buttons with aria-hidden', () => {
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector('svg');
+ expect(svg).not.toBeNull();
+ expect(svg?.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('sets draggable=false on images', () => {
+ render(
+ ,
+ );
+ const img = screen.getByAltText('Attached');
+ expect(img).toHaveAttribute('draggable', 'false');
+ });
+
+ it('applies opacity class when item is loading', () => {
+ render(
+ ,
+ );
+ const img = screen.getByAltText('Attached');
+ expect(img.classList.contains('opacity-50')).toBe(true);
+ });
+
+ it('does not apply opacity class when item is not loading', () => {
+ render(
+ ,
+ );
+ const img = screen.getByAltText('Attached');
+ expect(img.classList.contains('opacity-50')).toBe(false);
+ });
+
+ it('renders spinner when item is loading', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('.animate-spin')).not.toBeNull();
+ });
+
+ it('does not render spinner when item is not loading', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('.animate-spin')).toBeNull();
+ });
+});
diff --git a/src/hooks/__tests__/useConversationHistory.test.tsx b/src/hooks/__tests__/useConversationHistory.test.tsx
index 7d814584..c43c98de 100644
--- a/src/hooks/__tests__/useConversationHistory.test.tsx
+++ b/src/hooks/__tests__/useConversationHistory.test.tsx
@@ -9,7 +9,7 @@ const MESSAGES: Message[] = [
{ id: 'a1', role: 'assistant', content: 'Hi there' },
];
-const MODEL = 'llama3.2:3b';
+const MODEL = 'gemma3:4b';
describe('useConversationHistory', () => {
beforeEach(() => {
@@ -39,8 +39,18 @@ describe('useConversationHistory', () => {
expect(invoke).toHaveBeenCalledWith('save_conversation', {
messages: [
- { role: 'user', content: 'Hello', quoted_text: null },
- { role: 'assistant', content: 'Hi there', quoted_text: null },
+ {
+ role: 'user',
+ content: 'Hello',
+ quoted_text: null,
+ image_paths: null,
+ },
+ {
+ role: 'assistant',
+ content: 'Hi there',
+ quoted_text: null,
+ image_paths: null,
+ },
],
model: MODEL,
});
@@ -73,8 +83,18 @@ describe('useConversationHistory', () => {
expect(invoke).toHaveBeenCalledWith('generate_title', {
conversationId: 'conv-123',
messages: [
- { role: 'user', content: 'Hello', quoted_text: null },
- { role: 'assistant', content: 'Hi there', quoted_text: null },
+ {
+ role: 'user',
+ content: 'Hello',
+ quoted_text: null,
+ image_paths: null,
+ },
+ {
+ role: 'assistant',
+ content: 'Hi there',
+ quoted_text: null,
+ image_paths: null,
+ },
],
});
});
@@ -141,12 +161,14 @@ describe('useConversationHistory', () => {
role: 'user',
content: 'Follow up',
quotedText: 'ctx',
+ imagePaths: null,
});
expect(invoke).toHaveBeenCalledWith('persist_message', {
conversationId: 'conv-123',
role: 'assistant',
content: 'Reply',
quotedText: null,
+ imagePaths: null,
});
});
@@ -201,6 +223,7 @@ describe('useConversationHistory', () => {
role: 'user',
content: 'Saved question',
quoted_text: null,
+ image_paths: null,
created_at: 1,
},
{
@@ -208,6 +231,7 @@ describe('useConversationHistory', () => {
role: 'assistant',
content: 'Saved answer',
quoted_text: 'ctx',
+ image_paths: null,
created_at: 2,
},
]);
@@ -239,6 +263,37 @@ describe('useConversationHistory', () => {
]);
});
+ it('loadConversation() restores imagePaths from persisted JSON', async () => {
+ invoke.mockResolvedValueOnce([
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'Look at this',
+ quoted_text: null,
+ image_paths: '["/images/a.jpg","/images/b.jpg"]',
+ created_at: 1,
+ },
+ {
+ id: 'm2',
+ role: 'assistant',
+ content: 'I see',
+ quoted_text: null,
+ image_paths: null,
+ created_at: 2,
+ },
+ ]);
+
+ const { result } = renderHook(() => useConversationHistory());
+ let loaded: Message[] = [];
+
+ await act(async () => {
+ loaded = await result.current.loadConversation('conv-img');
+ });
+
+ expect(loaded[0].imagePaths).toEqual(['/images/a.jpg', '/images/b.jpg']);
+ expect(loaded[1].imagePaths).toBeUndefined();
+ });
+
it('loadConversation() sets conversationId to the loaded id', async () => {
invoke.mockResolvedValueOnce([]);
diff --git a/src/hooks/__tests__/useOllama.test.tsx b/src/hooks/__tests__/useOllama.test.tsx
index ddb4dfa6..f3b84ca0 100644
--- a/src/hooks/__tests__/useOllama.test.tsx
+++ b/src/hooks/__tests__/useOllama.test.tsx
@@ -221,6 +221,99 @@ describe('useOllama', () => {
});
});
+ // ─── imagePaths handling ─────────────────────────────────────────────────────
+
+ describe('imagePaths handling', () => {
+ it('allows ask() with empty text but valid imagePaths', async () => {
+ const { result } = renderHook(() => useOllama());
+
+ await act(async () => {
+ await result.current.ask('', undefined, ['/tmp/img1.jpg']);
+ });
+
+ // Should have created a user message (not returned early)
+ expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages[0]).toEqual(
+ expect.objectContaining({
+ role: 'user',
+ content: '',
+ imagePaths: ['/tmp/img1.jpg'],
+ }),
+ );
+ expect(invoke).toHaveBeenCalledWith(
+ 'ask_ollama',
+ expect.objectContaining({
+ message: '',
+ imagePaths: ['/tmp/img1.jpg'],
+ }),
+ );
+ });
+
+ it('returns early for empty text AND no imagePaths', async () => {
+ const { result } = renderHook(() => useOllama());
+
+ await act(async () => {
+ await result.current.ask('', undefined, undefined);
+ });
+
+ expect(invoke).not.toHaveBeenCalled();
+ expect(result.current.messages).toHaveLength(0);
+ });
+
+ it('returns early for empty text AND empty imagePaths array', async () => {
+ const { result } = renderHook(() => useOllama());
+
+ await act(async () => {
+ await result.current.ask('', undefined, []);
+ });
+
+ expect(invoke).not.toHaveBeenCalled();
+ expect(result.current.messages).toHaveLength(0);
+ });
+
+ it('includes imagePaths in message and invoke when text AND imagePaths are provided', async () => {
+ const { result } = renderHook(() => useOllama());
+
+ await act(async () => {
+ await result.current.ask('describe this', undefined, [
+ '/tmp/img1.jpg',
+ '/tmp/img2.jpg',
+ ]);
+ });
+
+ expect(result.current.messages[0]).toEqual(
+ expect.objectContaining({
+ role: 'user',
+ content: 'describe this',
+ imagePaths: ['/tmp/img1.jpg', '/tmp/img2.jpg'],
+ }),
+ );
+ expect(invoke).toHaveBeenCalledWith(
+ 'ask_ollama',
+ expect.objectContaining({
+ message: 'describe this',
+ imagePaths: ['/tmp/img1.jpg', '/tmp/img2.jpg'],
+ }),
+ );
+ });
+
+ it('sets message.imagePaths to undefined and invoke imagePaths to null when no imagePaths', async () => {
+ const { result } = renderHook(() => useOllama());
+
+ await act(async () => {
+ await result.current.ask('hello');
+ });
+
+ expect(result.current.messages[0].imagePaths).toBeUndefined();
+ expect(invoke).toHaveBeenCalledWith(
+ 'ask_ollama',
+ expect.objectContaining({
+ imagePaths: null,
+ }),
+ );
+ });
+ });
+
// ─── Error handling ──────────────────────────────────────────────────────────
describe('error handling', () => {
diff --git a/src/hooks/useConversationHistory.ts b/src/hooks/useConversationHistory.ts
index 0f38a7dd..3f031c80 100644
--- a/src/hooks/useConversationHistory.ts
+++ b/src/hooks/useConversationHistory.ts
@@ -17,6 +17,7 @@ function toPayload(msg: Message): SaveMessagePayload {
role: msg.role,
content: msg.content,
quoted_text: msg.quotedText ?? null,
+ image_paths: msg.imagePaths ?? null,
};
}
@@ -25,11 +26,15 @@ function toPayload(msg: Message): SaveMessagePayload {
* frontend `Message`, preserving optional `quotedText`.
*/
function fromPersisted(msg: PersistedMessage): Message {
+ const imagePaths = msg.image_paths
+ ? (JSON.parse(msg.image_paths) as string[])
+ : undefined;
return {
id: msg.id,
role: msg.role as 'user' | 'assistant',
content: msg.content,
quotedText: msg.quoted_text ?? undefined,
+ imagePaths: imagePaths && imagePaths.length > 0 ? imagePaths : undefined,
};
}
@@ -106,12 +111,14 @@ export function useConversationHistory() {
role: userMsg.role,
content: userMsg.content,
quotedText: userMsg.quotedText ?? null,
+ imagePaths: userMsg.imagePaths ?? null,
}),
invoke('persist_message', {
conversationId,
role: assistantMsg.role,
content: assistantMsg.content,
quotedText: assistantMsg.quotedText ?? null,
+ imagePaths: null,
}),
]);
},
diff --git a/src/hooks/useOllama.ts b/src/hooks/useOllama.ts
index c11b3061..ccf034d7 100644
--- a/src/hooks/useOllama.ts
+++ b/src/hooks/useOllama.ts
@@ -11,6 +11,8 @@ export interface Message {
content: string;
/** Selected text from the host app that was quoted with this message, if any. */
quotedText?: string;
+ /** Absolute file paths of images attached to this message, if any. */
+ imagePaths?: string[];
}
/**
@@ -49,16 +51,27 @@ export function useOllama(
*
* @param displayContent The user's query as it should appear in the chat bubble.
* @param quotedText Optional selected text quoted alongside this message.
+ * @param imagePaths Optional array of absolute file paths for attached images.
*/
const ask = useCallback(
- async (displayContent: string, quotedText?: string) => {
- if (!displayContent.trim() || isGenerating) return;
+ async (
+ displayContent: string,
+ quotedText?: string,
+ imagePaths?: string[],
+ ) => {
+ if (
+ (!displayContent.trim() && (!imagePaths || imagePaths.length === 0)) ||
+ isGenerating
+ )
+ return;
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: displayContent,
quotedText,
+ imagePaths:
+ imagePaths && imagePaths.length > 0 ? imagePaths : undefined,
};
setMessages((prev) => [...prev, userMsg]);
@@ -121,6 +134,7 @@ export function useOllama(
await invoke('ask_ollama', {
message: displayContent,
quotedText: quotedText ?? null,
+ imagePaths: imagePaths && imagePaths.length > 0 ? imagePaths : null,
onEvent: channel,
});
} catch (err) {
diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx
index b398a318..4c56327f 100644
--- a/src/testUtils/mocks/framer-motion.tsx
+++ b/src/testUtils/mocks/framer-motion.tsx
@@ -85,6 +85,19 @@ export const motion = {
{children}
),
+ img: ({
+ className,
+ src,
+ alt,
+ ...props
+ }: React.ImgHTMLAttributes & Record) => (
+
+ ),
};
export const AnimatePresence = ({
diff --git a/src/testUtils/mocks/tauri.ts b/src/testUtils/mocks/tauri.ts
index 0c19fded..753345f4 100644
--- a/src/testUtils/mocks/tauri.ts
+++ b/src/testUtils/mocks/tauri.ts
@@ -84,6 +84,13 @@ export function enableChannelCaptureWithResponses(
);
}
+// ─── convertFileSrc mock ────────────────────────────────────────────────────
+
+/** Returns a passthrough URL for test rendering (no Tauri asset protocol). */
+export function convertFileSrc(path: string): string {
+ return `asset://localhost/${encodeURIComponent(path)}`;
+}
+
// ─── listen mock ────────────────────────────────────────────────────────────
type EventCallback = (event: { payload: T }) => void;
diff --git a/src/testUtils/setup.ts b/src/testUtils/setup.ts
index 7fc75a59..5cbe61ed 100644
--- a/src/testUtils/setup.ts
+++ b/src/testUtils/setup.ts
@@ -7,6 +7,12 @@ import { clearEventHandlers, resetChannelCapture } from './mocks/tauri';
export const server = setupServer(...handlers);
+/**
+ * Counter for deterministic blob URL generation in tests.
+ * Reset between tests to ensure predictable URL values.
+ */
+let blobUrlCounter = 0;
+
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
@@ -16,6 +22,7 @@ afterEach(() => {
cleanup();
clearEventHandlers();
resetChannelCapture();
+ blobUrlCounter = 0;
vi.restoreAllMocks();
});
@@ -77,3 +84,12 @@ globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
return 0;
};
globalThis.cancelAnimationFrame = () => {};
+
+/**
+ * Mock URL.createObjectURL / revokeObjectURL: jsdom doesn't implement Blob URLs.
+ * Returns a deterministic fake blob URL so tests can assert against it.
+ */
+URL.createObjectURL = vi.fn(
+ () => `blob:http://localhost/fake-blob-${++blobUrlCounter}`,
+);
+URL.revokeObjectURL = vi.fn();
diff --git a/src/types/history.ts b/src/types/history.ts
index c35f94f6..b6e9a020 100644
--- a/src/types/history.ts
+++ b/src/types/history.ts
@@ -30,6 +30,8 @@ export interface PersistedMessage {
content: string;
/** Quoted host-app text attached to this message, if any. */
quoted_text: string | null;
+ /** JSON-encoded array of image file paths, if any. */
+ image_paths: string | null;
/** Unix timestamp (seconds) the message was created. */
created_at: number;
}
@@ -49,4 +51,5 @@ export interface SaveMessagePayload {
role: string;
content: string;
quoted_text: string | null;
+ image_paths: string[] | null;
}
diff --git a/src/types/image.ts b/src/types/image.ts
new file mode 100644
index 00000000..934de6fe
--- /dev/null
+++ b/src/types/image.ts
@@ -0,0 +1,18 @@
+/**
+ * Represents an image attached to the current (unsent) message.
+ *
+ * The `blobUrl` is available immediately on paste/drop for instant thumbnail
+ * rendering. The `filePath` is set asynchronously once the Rust backend
+ * finishes compressing and saving the image to disk.
+ */
+export interface AttachedImage {
+ /** Unique identifier for stable React list keys. */
+ id: string;
+ /** Browser object URL for instant thumbnail rendering (no disk round-trip). */
+ blobUrl: string;
+ /** Absolute file path on disk, set once Rust processing completes. */
+ filePath: string | null;
+}
+
+/** Maximum file size in bytes (30 MB). Files exceeding this are rejected. */
+export const MAX_IMAGE_SIZE_BYTES = 30 * 1024 * 1024;
diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx
index 6b8ca491..0325db52 100644
--- a/src/view/AskBarView.tsx
+++ b/src/view/AskBarView.tsx
@@ -1,8 +1,11 @@
import { motion } from 'framer-motion';
import type React from 'react';
-import { useCallback } from 'react';
+import { useCallback, useState } from 'react';
import { formatQuotedText } from '../utils/formatQuote';
import { quote } from '../config';
+import { ImageThumbnails } from '../components/ImageThumbnails';
+import type { AttachedImage } from '../types/image';
+import { MAX_IMAGE_SIZE_BYTES } from '../types/image';
/**
* Hoisted static SVG — prevents re-allocation on every render cycle.
@@ -114,9 +117,10 @@ const HISTORY_ICON = (
);
-/**
- * Props for the AskBarView component.
- */
+/** Maximum number of images allowed per message (mirrors MAX_IMAGES_PER_MESSAGE in images.rs). */
+const MAX_IMAGES = 3;
+
+/** Props for the AskBarView component. */
interface AskBarViewProps {
/** The current user input text. */
query: string;
@@ -126,6 +130,8 @@ interface AskBarViewProps {
isChatMode: boolean;
/** True if the AI is actively generating a response. */
isGenerating: boolean;
+ /** True while waiting for images to finish processing before submitting. */
+ isSubmitPending?: boolean;
/** Submit handler fired when the user commits their message. */
onSubmit: () => void;
/** Cancel handler fired when the user stops an active generation. */
@@ -139,6 +145,14 @@ interface AskBarViewProps {
* Omit to hide the history icon entirely.
*/
onHistoryOpen?: () => void;
+ /** Currently attached images (may still be processing in the background). */
+ attachedImages: AttachedImage[];
+ /** Called when the user pastes or drops image files. */
+ onImagesAttached: (files: File[]) => void;
+ /** Called when the user removes an attached image by ID. */
+ onImageRemove: (id: string) => void;
+ /** Called when the user clicks a thumbnail to preview it. */
+ onImagePreview: (id: string) => void;
}
/**
@@ -152,13 +166,22 @@ export function AskBarView({
setQuery,
isChatMode,
isGenerating,
+ isSubmitPending = false,
onSubmit,
onCancel,
inputRef,
selectedText,
onHistoryOpen,
+ attachedImages,
+ onImagesAttached,
+ onImageRemove,
+ onImagePreview,
}: AskBarViewProps) {
- const canSubmit = query.trim().length > 0 && !isGenerating;
+ /** True when the UI should be locked — either generating or waiting for images. */
+ const isBusy = isGenerating || isSubmitPending;
+ const canSubmit =
+ (query.trim().length > 0 || attachedImages.length > 0) && !isBusy;
+ const [isDragOver, setIsDragOver] = useState(false);
/**
* Auto-resizes the textarea to fit its content up to a maximum height.
@@ -188,8 +211,86 @@ export function AskBarView({
[onSubmit],
);
+ /**
+ * Filters and forwards valid image files to the parent for processing.
+ * Rejects non-image files and files exceeding the 30MB size cap.
+ */
+ const processImageFiles = useCallback(
+ (files: FileList | null) => {
+ if (!files || isBusy) return;
+ const remaining = MAX_IMAGES - attachedImages.length;
+ if (remaining <= 0) return;
+
+ const accepted: File[] = [];
+ for (let i = 0; i < files.length && accepted.length < remaining; i++) {
+ if (
+ files[i].type.startsWith('image/') &&
+ files[i].size <= MAX_IMAGE_SIZE_BYTES
+ ) {
+ accepted.push(files[i]);
+ }
+ }
+ if (accepted.length > 0) {
+ onImagesAttached(accepted);
+ }
+ },
+ [isBusy, attachedImages.length, onImagesAttached],
+ );
+
+ /** Handles clipboard paste — extracts image items from clipboardData. */
+ const handlePaste = useCallback(
+ (e: React.ClipboardEvent) => {
+ const items = e.clipboardData?.items;
+ if (!items || isBusy) return;
+
+ const remaining = MAX_IMAGES - attachedImages.length;
+ if (remaining <= 0) return;
+
+ const imageFiles: File[] = [];
+ for (let i = 0; i < items.length && imageFiles.length < remaining; i++) {
+ if (items[i].type.startsWith('image/')) {
+ const file = items[i].getAsFile();
+ if (file && file.size <= MAX_IMAGE_SIZE_BYTES) {
+ imageFiles.push(file);
+ }
+ }
+ }
+
+ if (imageFiles.length === 0) return;
+ e.preventDefault();
+ onImagesAttached(imageFiles);
+ },
+ [isBusy, attachedImages.length, onImagesAttached],
+ );
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ if (!isBusy) setIsDragOver(true);
+ },
+ [isBusy],
+ );
+
+ const handleDragLeave = useCallback(() => {
+ setIsDragOver(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ processImageFiles(e.dataTransfer?.files ?? null);
+ },
+ [processImageFiles],
+ );
+
return (
-
+
{selectedText && (
@@ -203,6 +304,20 @@ export function AskBarView({
)}
+ {attachedImages.length > 0 && (
+
+ ({
+ id: img.id,
+ src: img.blobUrl,
+ loading: img.filePath === null,
+ }))}
+ onPreview={onImagePreview}
+ onRemove={onImageRemove}
+ size={56}
+ />
+
+ )}

- {isGenerating ? (
+ {isBusy ? (
<>
{BORDER_TRACE_RING}
{STOP_ICON}
diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx
index 4d2efb35..67ad1c07 100644
--- a/src/view/ConversationView.tsx
+++ b/src/view/ConversationView.tsx
@@ -45,6 +45,8 @@ interface ConversationViewProps {
* Omit to hide the button.
*/
onNewConversation?: () => void;
+ /** Called when the user clicks a thumbnail to preview it. */
+ onImagePreview?: (path: string) => void;
}
/**
@@ -68,6 +70,7 @@ export function ConversationView({
canSave,
onHistoryOpen,
onNewConversation,
+ onImagePreview,
}: ConversationViewProps) {
const scrollContainerRef = useRef
(null);
@@ -176,6 +179,8 @@ export function ConversationView({
content={msg.content}
quotedText={msg.quotedText}
index={i}
+ imagePaths={msg.imagePaths}
+ onImagePreview={onImagePreview}
/>
))}
diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx
index e17652aa..636d09f5 100644
--- a/src/view/__tests__/AskBarView.test.tsx
+++ b/src/view/__tests__/AskBarView.test.tsx
@@ -2,15 +2,35 @@ import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { AskBarView } from '../AskBarView';
+import type { AttachedImage } from '../../types/image';
function makeRef(): React.RefObject {
return { current: null };
}
+/** Helper to create an AttachedImage with defaults. */
+function makeImage(overrides: Partial = {}): AttachedImage {
+ return {
+ id: overrides.id ?? 'test-id',
+ blobUrl: overrides.blobUrl ?? 'blob:http://localhost/test',
+ filePath: overrides.filePath ?? '/tmp/img.jpg',
+ ...overrides,
+ };
+}
+
+/** Default image-related props shared across all AskBarView test renders. */
+const IMAGE_DEFAULTS = {
+ attachedImages: [] as AttachedImage[],
+ onImagesAttached: vi.fn(),
+ onImageRemove: vi.fn(),
+ onImagePreview: vi.fn(),
+};
+
describe('AskBarView', () => {
it('renders textarea with placeholder for input bar mode', () => {
render(
{
it('renders textarea with chat mode placeholder', () => {
render(
{
const setQuery = vi.fn();
render(
{
it('disables textarea during generation', () => {
render(
{
const onSubmit = vi.fn();
render(
{
const onSubmit = vi.fn();
render(
{
const onSubmit = vi.fn();
render(
{
it('shows logo at 40px in input bar mode (w-10 h-10 rounded-xl classes)', () => {
const { container } = render(
{
it('shows logo at 24px in chat mode (w-6 h-6 rounded-lg classes)', () => {
const { container } = render(
{
it('shows send button with accessible label', () => {
render(
{
it('displays selectedText when provided', () => {
render(
{
it('hides context area when no selectedText', () => {
const { container } = render(
{
it('shows stop button with accessible label during generation', () => {
render(
{
const onCancel = vi.fn();
render(
{
it('applies spinning ring class to stop button', () => {
render(
{
const onSubmit = vi.fn();
render(
{
it('displays selectedText with whitespace-pre-wrap class', () => {
const { container } = render(
{
it('renders history icon button in ask-bar mode when onHistoryOpen is provided', () => {
render(
{
it('does not render history icon button in chat mode', () => {
render(
{
const onHistoryOpen = vi.fn();
render(
{
expect(onHistoryOpen).toHaveBeenCalledOnce();
});
});
+
+ describe('image attachments', () => {
+ it('renders image thumbnails when attachedImages is non-empty', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole('list', { name: /attached images/i }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
+ });
+
+ it('does not render thumbnails when attachedImages is empty', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole('list', { name: /attached images/i }),
+ ).toBeNull();
+ });
+
+ it('enables submit button when images are attached even without text', () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole('button', { name: 'Send message' });
+ expect(btn).not.toBeDisabled();
+ });
+
+ it('calls onImagePreview when thumbnail is clicked', () => {
+ const onImagePreview = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /preview/i }));
+ expect(onImagePreview).toHaveBeenCalledWith('img-1');
+ });
+
+ it('calls onImageRemove when remove button is clicked', () => {
+ const onImageRemove = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }));
+ expect(onImageRemove).toHaveBeenCalledWith('img-1');
+ });
+
+ it('applies drag-over styling on dragOver event', () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ fireEvent.dragOver(wrapper, { preventDefault: vi.fn() });
+ expect(wrapper.classList.contains('ring-2')).toBe(true);
+ });
+
+ it('removes drag-over styling on dragLeave', () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ fireEvent.dragOver(wrapper, { preventDefault: vi.fn() });
+ fireEvent.dragLeave(wrapper);
+ expect(wrapper.classList.contains('ring-2')).toBe(false);
+ });
+
+ it('removes drag-over styling on drop', () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ fireEvent.dragOver(wrapper, { preventDefault: vi.fn() });
+ fireEvent.drop(wrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [] },
+ });
+ expect(wrapper.classList.contains('ring-2')).toBe(false);
+ });
+
+ it('calls onImagesAttached on drop with image files', async () => {
+ const onImagesAttached = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ const file = new File(['fake-img-data'], 'photo.png', {
+ type: 'image/png',
+ });
+ fireEvent.drop(wrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file] },
+ });
+ await vi.waitFor(() => {
+ expect(onImagesAttached).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('ignores non-image files on drop', () => {
+ const onImagesAttached = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ const file = new File(['text'], 'doc.txt', { type: 'text/plain' });
+ fireEvent.drop(wrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file] },
+ });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('ignores drop when already at max images', () => {
+ const onImagesAttached = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ const file = new File(['x'], 'img.png', { type: 'image/png' });
+ fireEvent.drop(wrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file] },
+ });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('ignores drop when generating', () => {
+ const onImagesAttached = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ const file = new File(['x'], 'img.png', { type: 'image/png' });
+ fireEvent.drop(wrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: [file] },
+ });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('ignores drop with null files', () => {
+ const onImagesAttached = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ fireEvent.drop(wrapper, {
+ preventDefault: vi.fn(),
+ dataTransfer: { files: null },
+ });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('does not apply drag-over styling when generating', () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ fireEvent.dragOver(wrapper, { preventDefault: vi.fn() });
+ expect(wrapper.classList.contains('ring-2')).toBe(false);
+ });
+
+ it('calls onImagesAttached on paste with image', async () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['fake-img'], 'test.png', { type: 'image/png' });
+ const clipboardData = {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ };
+ fireEvent.paste(textarea, { clipboardData });
+ // FileReader is async — wait for the next microtask.
+ await vi.waitFor(() => {
+ expect(onImagesAttached).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('does not call onImagesAttached on paste with text only', () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const clipboardData = {
+ items: [{ type: 'text/plain', getAsFile: () => null }],
+ };
+ fireEvent.paste(textarea, { clipboardData });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('ignores paste when clipboard has no items', () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ fireEvent.paste(textarea, { clipboardData: { items: null } });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('ignores paste when generating', () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['x'], 'img.png', { type: 'image/png' });
+ const clipboardData = {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ };
+ fireEvent.paste(textarea, { clipboardData });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('skips image items where getAsFile returns null', () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const clipboardData = {
+ items: [{ type: 'image/png', getAsFile: () => null }],
+ };
+ fireEvent.paste(textarea, { clipboardData });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+
+ it('respects max image limit during paste', async () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['x'], 'img.png', { type: 'image/png' });
+ const clipboardData = {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ };
+ fireEvent.paste(textarea, { clipboardData });
+ // Should not process since we're already at max.
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('isSubmitPending state', () => {
+ it('shows stop button when isSubmitPending is true', () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole('button', { name: /stop/i });
+ expect(btn).toBeInTheDocument();
+ expect(btn.classList.contains('stop-btn-ring')).toBe(true);
+ });
+
+ it('disables textarea when isSubmitPending is true', () => {
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ expect((textarea as HTMLTextAreaElement).disabled).toBe(true);
+ });
+
+ it('does not apply drag-over styling when isSubmitPending', () => {
+ const { container } = render(
+ ,
+ );
+ const wrapper = container.firstElementChild!;
+ fireEvent.dragOver(wrapper, { preventDefault: vi.fn() });
+ expect(wrapper.classList.contains('ring-2')).toBe(false);
+ });
+
+ it('ignores paste when isSubmitPending', () => {
+ const onImagesAttached = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
+ const file = new File(['x'], 'img.png', { type: 'image/png' });
+ const clipboardData = {
+ items: [{ type: 'image/png', getAsFile: () => file }],
+ };
+ fireEvent.paste(textarea, { clipboardData });
+ expect(onImagesAttached).not.toHaveBeenCalled();
+ });
+ });
});