diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 199be9fcdc..154f8113d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -51,6 +51,7 @@ pub mod spaces; pub mod terminals; pub mod titles; mod transcript_history; +pub mod transcript_search; pub mod uploads; pub mod voice; pub mod workspace_files; @@ -165,6 +166,8 @@ pub struct EngineCore { pub viewport_focus: desktop_notify::ViewportFocus, /// Local→synced profile import (account-scoped runtimes only). pub local_import: Option, + /// `None` when the index cannot open; search is then unavailable. + pub transcript_search: Option, workspace_scope: WorkspaceScope, /// Auth service (attached by [`Engine::run`]; a lazy dev-mode instance otherwise). auth: std::sync::Mutex>, @@ -245,6 +248,7 @@ impl EngineCore { registry.load_prefs(data_dir); let store = Arc::new(DocsStore::open(profile.store_root())?); let store_for_import = store.clone(); + let store_for_search = store.clone(); let journal = Arc::new(RunJournal::open(profile.store_root().join("journals"))?); let sessions = SessionsEngine::new(device_id.clone(), journal, registry.clone()); let doc_host = DocHost::new( @@ -268,6 +272,13 @@ impl EngineCore { local_only: matches!(profile.scope(), WorkspaceScope::Local), }, )?; + let transcript_search = transcript_search::TranscriptSearch::open( + profile.store_root(), + store_for_search, + workspace.clone(), + ) + .inspect_err(|error| tracing::warn!(%error, "transcript search unavailable")) + .ok(); doc_host.set_workspace(workspace.clone()); doc_host.set_sessions(sessions.clone()); sessions.set_doc_host(doc_host.clone()); @@ -380,6 +391,7 @@ impl EngineCore { harness_updates, device_id, local_import, + transcript_search, workspace_scope: profile.scope(), auth: std::sync::Mutex::new(None), links: std::sync::Mutex::new(None), @@ -535,6 +547,9 @@ impl EngineCore { if let Some(broker) = self.credentials() { rpc = rpc.with_credentials(broker); } + if let Some(search) = self.transcript_search.clone() { + rpc = rpc.with_transcript_search(search); + } Arc::new(rpc) } diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 3df1bb4df0..783fdf44d8 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -799,6 +799,7 @@ pub struct EngineRpc { credentials: Option, /// Viewports' window focus, for desktop banners. viewport_focus: crate::desktop_notify::ViewportFocus, + transcript_search: Option, engine_info: EngineInfo, } @@ -858,6 +859,7 @@ impl EngineRpc { local_import: None, credentials: None, viewport_focus: Default::default(), + transcript_search: None, engine_info, } } @@ -926,6 +928,14 @@ impl EngineRpc { self } + pub fn with_transcript_search( + mut self, + search: crate::transcript_search::TranscriptSearch, + ) -> Self { + self.transcript_search = Some(search); + self + } + fn auth(&self) -> Result<&Auth, RpcError> { self.auth .as_ref() @@ -3242,6 +3252,18 @@ impl RpcService for EngineRpc { methods::WATCH_CHATS => { Ok(RpcReply::Stream(watch_stream(self.workspace.watch_chats()))) } + methods::SEARCH_TRANSCRIPTS => { + let request: zeron_proto::SearchTranscriptsRequest = parse_params(params)?; + let search = self + .transcript_search + .as_ref() + .ok_or_else(|| RpcError::Failed("transcript search unavailable".into()))?; + let hits = search + .search(request.query, request.limit) + .await + .map_err(|e| RpcError::Failed(e.to_string()))?; + RpcReply::value(&hits) + } methods::WATCH_SIDEBAR_PREFERENCES => Ok(RpcReply::Stream(watch_stream( self.workspace.watch_sidebar_preferences(), ))), diff --git a/crates/engine/src/transcript_search.rs b/crates/engine/src/transcript_search.rs new file mode 100644 index 0000000000..8ea3257abd --- /dev/null +++ b/crates/engine/src/transcript_search.rs @@ -0,0 +1,267 @@ +//! Full-text search over transcripts stored on this engine. +//! +//! The index is derived data. `transcript-search.sqlite3` holds one FTS5 row +//! per chat and the snapshot `saved_at` it was built from. Refresh diffs the +//! index against `DocsStore` snapshots of the workspace's live chats, so +//! anything that writes a snapshot gets indexed with no hook. That covers live +//! persistence, chat2 seeding, and profile import. Deleting the file only +//! costs a rebuild. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use rusqlite::{Connection, params}; +use zeron_doc::{MessagePart, MessageRole, SessionDoc}; +use zeron_proto::TranscriptSearchHit; +use zeron_sync::DocsStore; + +use crate::EngineError; +use crate::workspace_host::WorkspaceHost; + +const SCHEMA_VERSION: i64 = 2; +/// A message's FTS rowid is `chat row * MESSAGE_SLOTS + message index`, so a +/// chat's messages are one rowid range. +// ponytail: messages past a million in one chat go unindexed. +const MESSAGE_SLOTS: i64 = 1_000_000; +const REFRESH_BUDGET: Duration = Duration::from_secs(1); +const SNIPPET_TOKENS: i64 = 16; + +#[derive(Clone)] +pub struct TranscriptSearch { + inner: Arc, +} + +struct Inner { + index: Mutex, + refreshing: Mutex<()>, + store: Arc, + workspace: WorkspaceHost, +} + +impl TranscriptSearch { + pub fn open( + dir: &Path, + store: Arc, + workspace: WorkspaceHost, + ) -> Result { + let conn = Connection::open(dir.join("transcript-search.sqlite3")).map_err(sql)?; + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(sql)?; + conn.pragma_update(None, "synchronous", "NORMAL") + .map_err(sql)?; + let version: i64 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(sql)?; + if version != SCHEMA_VERSION { + conn.execute_batch(&format!( + "DROP TABLE IF EXISTS chats; + DROP TABLE IF EXISTS transcripts; + CREATE TABLE chats ( + id INTEGER PRIMARY KEY, + chat_id TEXT NOT NULL UNIQUE, + saved_at INTEGER NOT NULL + ) STRICT; + CREATE VIRTUAL TABLE transcripts USING fts5( + body, from_user UNINDEXED, + tokenize = 'unicode61 remove_diacritics 2' + ); + PRAGMA user_version = {SCHEMA_VERSION};" + )) + .map_err(sql)?; + } + Ok(Self { + inner: Arc::new(Inner { + index: Mutex::new(conn), + refreshing: Mutex::new(()), + store, + workspace, + }), + }) + } + + /// An empty query only refreshes, so callers can warm the index. + pub async fn search( + &self, + query: String, + limit: u16, + ) -> Result, EngineError> { + let inner = self.inner.clone(); + tokio::task::spawn_blocking(move || { + if let Err(error) = inner.refresh() { + tracing::warn!(%error, "transcript index refresh failed"); + } + match match_expression(&query) { + Some(expr) => inner.query(&expr, limit), + None => Ok(Vec::new()), + } + }) + .await + .map_err(|e| EngineError::Other(e.to_string()))? + } +} + +impl Inner { + /// Reindex chats whose snapshot changed, newest first, within + /// [`REFRESH_BUDGET`]. A refresh already in flight makes this a no-op. + fn refresh(&self) -> Result<(), EngineError> { + let Ok(_refreshing) = self.refreshing.try_lock() else { + return Ok(()); + }; + let started = Instant::now(); + let live: HashSet = self + .workspace + .read_chats()? + .into_iter() + .map(|chat| chat.id) + .collect(); + let mut wanted: Vec<(String, i64)> = self + .store + .snapshot_saved_at()? + .into_iter() + .filter(|(id, _)| live.contains(id)) + .collect(); + let indexed: HashMap = { + let conn = self.conn(); + let mut stmt = conn + .prepare("SELECT chat_id, id, saved_at FROM chats") + .map_err(sql)?; + stmt.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?)))) + .map_err(sql)? + .collect::>() + .map_err(sql)? + }; + let wanted_ids: HashSet<&str> = wanted.iter().map(|(id, _)| id.as_str()).collect(); + let gone: Vec = indexed + .iter() + .filter(|(chat_id, _)| !wanted_ids.contains(chat_id.as_str())) + .map(|(_, (row, _))| *row) + .collect(); + if !gone.is_empty() { + let mut conn = self.conn(); + let tx = conn.transaction().map_err(sql)?; + for row in gone { + delete_messages(&tx, row)?; + tx.execute("DELETE FROM chats WHERE id = ?1", params![row]) + .map_err(sql)?; + } + tx.commit().map_err(sql)?; + } + wanted.retain(|(id, saved_at)| indexed.get(id).map(|(_, at)| at) != Some(saved_at)); + wanted.sort_by_key(|(_, saved_at)| std::cmp::Reverse(*saved_at)); + for (chat_id, saved_at) in wanted { + if started.elapsed() > REFRESH_BUDGET { + break; + } + let messages = match self.store.load_snapshot(&chat_id) { + Ok(Some(bytes)) => transcript_messages(&bytes).unwrap_or_else(|error| { + tracing::warn!(chat = %chat_id, %error, "transcript unreadable; indexed empty"); + Vec::new() + }), + Ok(None) => continue, + Err(error) => return Err(error.into()), + }; + let mut conn = self.conn(); + let tx = conn.transaction().map_err(sql)?; + let row: i64 = tx + .query_row( + "INSERT INTO chats (chat_id, saved_at) VALUES (?1, ?2) + ON CONFLICT(chat_id) DO UPDATE SET saved_at = excluded.saved_at + RETURNING id", + params![chat_id, saved_at], + |row| row.get(0), + ) + .map_err(sql)?; + delete_messages(&tx, row)?; + for (ix, (from_user, body)) in (0..MESSAGE_SLOTS).zip(messages) { + tx.execute( + "INSERT INTO transcripts (rowid, body, from_user) VALUES (?1, ?2, ?3)", + params![row * MESSAGE_SLOTS + ix, body, from_user], + ) + .map_err(sql)?; + } + tx.commit().map_err(sql)?; + } + Ok(()) + } + + fn query(&self, expr: &str, limit: u16) -> Result, EngineError> { + let conn = self.conn(); + let mut stmt = conn + .prepare_cached( + "SELECT chats.chat_id, snippet(transcripts, 0, '', '', '…', ?3), + transcripts.from_user + FROM transcripts + JOIN chats ON chats.id = transcripts.rowid / ?4 + WHERE transcripts MATCH ?1 + ORDER BY rank + LIMIT ?2", + ) + .map_err(sql)?; + stmt.query_map(params![expr, limit, SNIPPET_TOKENS, MESSAGE_SLOTS], |row| { + Ok(TranscriptSearchHit { + chat_id: row.get(0)?, + snippet: row.get(1)?, + from_user: row.get(2)?, + }) + }) + .map_err(sql)? + .collect::>() + .map_err(sql) + } + + fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { + self.index.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +fn delete_messages(tx: &rusqlite::Transaction<'_>, chat_row: i64) -> Result<(), EngineError> { + tx.execute( + "DELETE FROM transcripts WHERE rowid >= ?1 AND rowid < ?2", + params![chat_row * MESSAGE_SLOTS, (chat_row + 1) * MESSAGE_SLOTS], + ) + .map_err(sql)?; + Ok(()) +} + +/// `(from user, text)` per user or assistant message. Reasoning is hidden in +/// the UI, and tool output would drown real matches. +fn transcript_messages(snapshot: &[u8]) -> Result, EngineError> { + let raw = loro::LoroDoc::new(); + raw.import(snapshot) + .map_err(|e| EngineError::Other(e.to_string()))?; + let entries = SessionDoc::from_doc(raw).read_entries()?; + let mut messages = Vec::new(); + for entry in entries { + if !matches!(entry.role, MessageRole::User | MessageRole::Assistant) { + continue; + } + let mut body = String::new(); + for part in entry.parts { + if let MessagePart::Text { text, .. } = part { + body.push_str(&text); + body.push('\n'); + } + } + if !body.trim().is_empty() { + messages.push((entry.role == MessageRole::User, body)); + } + } + Ok(messages) +} + +/// Every word must appear, each as a quoted prefix so FTS5 operators in user +/// text stay literal. Words without a letter or digit have no tokens to match. +fn match_expression(query: &str) -> Option { + let terms: Vec = query + .split_whitespace() + .filter(|word| word.chars().any(char::is_alphanumeric)) + .map(|word| format!("\"{}\"*", word.replace('"', "\"\""))) + .collect(); + (!terms.is_empty()).then(|| terms.join(" ")) +} + +fn sql(error: rusqlite::Error) -> EngineError { + EngineError::Other(format!("transcript index: {error}")) +} diff --git a/crates/engine/tests/transcript_search.rs b/crates/engine/tests/transcript_search.rs new file mode 100644 index 0000000000..9bf553a480 --- /dev/null +++ b/crates/engine/tests/transcript_search.rs @@ -0,0 +1,247 @@ +//! `SearchTranscripts` end to end. Two chats run mock-harness turns queued +//! over the RPC transport, then the palette's search call finds words that +//! exist only inside their transcripts. Covers prefix matching, FTS operator +//! text, hidden reasoning, chat deletion, restart, and rebuilding a deleted +//! index. Writes the observed replies to `target/transcript-search-e2e.json`. + +use std::sync::Arc; +use std::time::Duration; + +use zeron_doc::{MessageRole, MessageStatus, SessionCommandPayload}; +use zeron_engine::{EngineCore, HarnessRegistry}; +use zeron_harness::mock::MockHarness; +use zeron_proto::{AgentEvent, DoneStatus, HarnessId, RunRequest, SandboxLevel}; +use zeron_rpc::{RpcClient, methods}; + +const DEPLOY: &str = "chat-deploy"; +const LUNCH: &str = "chat-lunch"; + +fn script() -> Vec { + vec![ + AgentEvent::ReasoningDelta { + text: "hiddenthought".into(), + }, + AgentEvent::TextDelta { + text: "The rollout stalled on a stale Kubernetes configmap.".into(), + }, + AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }, + ] +} + +fn assemble(dir: &std::path::Path) -> EngineCore { + let registry = HarnessRegistry::new(); + registry.register(Arc::new(MockHarness { script: script() })); + EngineCore::assemble(dir, Arc::new(registry), HarnessId::Mock, None).expect("engine assembles") +} + +async fn create_chat(client: &RpcClient, core: &EngineCore, chat: &str) { + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "createChat", "chatId": chat, "deviceId": core.device_id }), + ) + .await + .expect("createChat"); + core.workspace + .rename_chat(chat, "Untitled") + .expect("pre-title keeps the titler out"); +} + +async fn run_turn(client: &RpcClient, chat: &str, prompt: &str) { + let command = serde_json::to_value(SessionCommandPayload::Run { + request: RunRequest { + prompt: prompt.into(), + harness: None, + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + attachments: Vec::new(), + worktree: None, + resume: None, + mcp: None, + env: Default::default(), + }, + message_id: format!("{chat}-m1"), + }) + .unwrap(); + client + .call( + methods::QUEUE_COMMAND, + serde_json::json!({ "chatId": chat, "command": command }), + ) + .await + .expect("QueueCommand"); +} + +fn turn_complete(core: &EngineCore, chat: &str) -> bool { + core.doc_host + .open(chat) + .ok() + .and_then(|h| h.doc().read_entries().ok()) + .unwrap_or_default() + .iter() + .any(|e| e.role == MessageRole::Assistant && e.status == Some(MessageStatus::Complete)) +} + +async fn search(client: &RpcClient, query: &str) -> Vec<(String, String)> { + let reply = client + .call( + methods::SEARCH_TRANSCRIPTS, + serde_json::json!({ "query": query, "limit": 30 }), + ) + .await + .unwrap_or_else(|e| panic!("SearchTranscripts {query:?}: {e}")); + let hits: Vec = serde_json::from_value(reply).unwrap(); + let mut hits: Vec<_> = hits.into_iter().map(|h| (h.chat_id, h.snippet)).collect(); + hits.sort(); + // One hit per message: compare chats, first snippet each. + hits.dedup_by(|a, b| a.0 == b.0); + hits +} + +async fn chats_for(client: &RpcClient, query: &str) -> Vec { + search(client, query) + .await + .into_iter() + .map(|(id, _)| id) + .collect() +} + +async fn eventually(client: &RpcClient, query: &str, want: &[&str]) -> Vec<(String, String)> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let hits = search(client, query).await; + if hits + .iter() + .map(|(id, _)| id.as_str()) + .eq(want.iter().copied()) + { + return hits; + } + assert!( + tokio::time::Instant::now() < deadline, + "{query:?}: wanted {want:?}, still {hits:?}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn palette_search_finds_words_inside_transcripts() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("data"); + let mut log = serde_json::Map::new(); + + let core = assemble(&data); + let client = zeron_rpc::memory_client(core.rpc_service()); + create_chat(&client, &core, DEPLOY).await; + create_chat(&client, &core, LUNCH).await; + run_turn(&client, DEPLOY, "why does the pipeline fail on tuesdays?").await; + run_turn(&client, LUNCH, "where should we get lunch").await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while !(turn_complete(&core, DEPLOY) && turn_complete(&core, LUNCH)) { + assert!(tokio::time::Instant::now() < deadline, "turns complete"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let user_only = eventually(&client, "tuesdays", &[DEPLOY]).await; + assert!( + user_only[0].1.contains("tuesdays"), + "snippet shows the match: {user_only:?}" + ); + log.insert("tuesdays".into(), serde_json::json!(user_only)); + + let assistant = eventually(&client, "kube config", &[DEPLOY, LUNCH]).await; + log.insert("kube config".into(), serde_json::json!(assistant)); + + assert!( + chats_for(&client, "hiddenthought").await.is_empty(), + "reasoning is not indexed" + ); + assert!( + chats_for(&client, "lunch tuesdays").await.is_empty(), + "all words must match" + ); + assert!( + chats_for(&client, "").await.is_empty(), + "empty query only warms" + ); + for hostile in ["-", "\"", "NEAR(", "lunch OR", "a:b*", "\"lunch"] { + let hits = chats_for(&client, hostile).await; + log.insert(format!("hostile {hostile}"), serde_json::json!(hits)); + } + assert_eq!(chats_for(&client, "\"lunch").await, vec![LUNCH.to_string()]); + + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "deleteChat", "chatId": LUNCH }), + ) + .await + .expect("deleteChat"); + let after_delete = eventually(&client, "configmap", &[DEPLOY]).await; + log.insert( + "configmap after delete".into(), + serde_json::json!(after_delete), + ); + + core.shutdown().await; + drop(client); + drop(core); + + let core = assemble(&data); + let client = zeron_rpc::memory_client(core.rpc_service()); + let restarted = eventually(&client, "tuesdays", &[DEPLOY]).await; + log.insert( + "tuesdays after restart".into(), + serde_json::json!(restarted), + ); + core.shutdown().await; + drop(client); + drop(core); + + let index = walk(&data) + .into_iter() + .find(|p| { + p.file_name() + .is_some_and(|n| n == "transcript-search.sqlite3") + }) + .expect("index file exists under the profile store"); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", index.display())); + } + let core = assemble(&data); + let client = zeron_rpc::memory_client(core.rpc_service()); + let rebuilt = eventually(&client, "tuesdays", &[DEPLOY]).await; + log.insert( + "tuesdays after index deleted".into(), + serde_json::json!(rebuilt), + ); + core.shutdown().await; + + let artifact = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/transcript-search-e2e.json"); + std::fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + std::fs::write(&artifact, serde_json::to_string_pretty(&log).unwrap()).unwrap(); +} + +fn walk(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + if path.is_dir() { + out.extend(walk(&path)); + } else { + out.push(path); + } + } + out +} diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 6aaa3bd21b..70572968a7 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -672,6 +672,24 @@ pub enum WorkspaceEntryKind { Symlink, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchTranscriptsRequest { + pub query: String, + pub limit: u16, +} + +/// One chat whose user or assistant text matched. Results arrive best match first. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptSearchHit { + pub chat_id: String, + pub snippet: String, + /// The matched message is the user's (else the agent's). + #[serde(default)] + pub from_user: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchWorkspaceFilesRequest { diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 7bd0d1801b..d042f559c0 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -125,6 +125,9 @@ pub mod methods { /// the sending thumbnail's percent-ring feed. No params; IPC-only. pub const WATCH_TRANSFERS: &str = "WatchTransfers"; pub const WATCH_CHATS: &str = "WatchChats"; + /// Full-text search over transcripts stored on this engine. + /// `SearchTranscriptsRequest` → `TranscriptSearchHit[]`. Never relay-forwarded. + pub const SEARCH_TRANSCRIPTS: &str = "SearchTranscripts"; pub const WATCH_SIDEBAR_PREFERENCES: &str = "WatchSidebarPreferences"; pub const WATCH_DEVICES: &str = "WatchDevices"; pub const WATCH_SESSIONS: &str = "WatchSessions"; diff --git a/crates/sync/src/store.rs b/crates/sync/src/store.rs index d5ff71b1f1..03caa6cf85 100644 --- a/crates/sync/src/store.rs +++ b/crates/sync/src/store.rs @@ -95,6 +95,13 @@ const VACUUM_MIN_FREE_DENOMINATOR: i64 = 4; /// Maintenance is skipped (and logged) rather than waited on when another /// connection holds a lock. const MAINTENANCE_BUSY_TIMEOUT: Duration = Duration::from_millis(250); +/// Idempotent and outside `MIGRATIONS`, so it claims no migration version: a +/// build without it (an older release, or one whose next migration reuses the +/// slot) neither needs it nor skips a migration because of it. Covering, +/// because `saved_at` sits after the blob and reading it from the table walks +/// every snapshot's overflow pages. +const SNAPSHOT_FRESHNESS_INDEX: &str = + "CREATE INDEX IF NOT EXISTS snapshots_saved_at ON snapshots(doc_id, saved_at);"; /// SQLite-backed store under a data directory (`{data_dir}/docs.sqlite3`). /// @@ -484,6 +491,17 @@ impl DocsStore { Ok(()) } + /// `saved_at` for every snapshot row, read from the covering index. + pub fn snapshot_saved_at(&self) -> Result, StoreError> { + store_blocking(|| { + let conn = self.conn(); + let mut stmt = conn + .prepare("SELECT doc_id, saved_at FROM snapshots INDEXED BY snapshots_saved_at")?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + Ok(rows.collect::>()?) + }) + } + /// Whether a snapshot row exists for `doc_id` — presence only, no blob read. pub fn has_snapshot(&self, doc_id: &str) -> Result { store_blocking(|| self.has_snapshot_blocking(doc_id)) @@ -645,6 +663,7 @@ fn migrate(conn: &mut Connection) -> Result<(), StoreError> { )?; tx.commit()?; } + conn.execute_batch(SNAPSHOT_FRESHNESS_INDEX)?; Ok(()) } diff --git a/crates/ui/src/find.rs b/crates/ui/src/find.rs new file mode 100644 index 0000000000..7cc07f835e --- /dev/null +++ b/crates/ui/src/find.rs @@ -0,0 +1,214 @@ +//! Find-in-thread state and matching for the transcript. +//! +//! Matches are counted per rendered row (user text, markdown blocks) and +//! stepped occurrence by occurrence; the painters wash them inline. + +use gpui::{Entity, FocusHandle, SharedString, Subscription}; + +use crate::composer::ComposerInput; +use crate::markdown::{Block, InlineRun}; + +/// The find field's height, matching the popover rows' card rhythm. +pub(crate) const BAR_HEIGHT: f32 = 38.0; + +/// The open find bar's input, query and match cursor. +pub(crate) struct FindState { + pub(crate) input: Entity, + pub(crate) query: String, + /// Every occurrence as `(row index, nth match in that row)`, in + /// transcript order. + pub(crate) matches: Vec<(usize, usize)>, + /// Index into [`Self::matches`], or `None` while nothing matches. + pub(crate) current: Option, + pub(crate) previous_focus: Option, + pub(crate) focus_pending: bool, + /// Jump to the first match once one loads (opened from global search + /// before the chat's rows arrived). + pub(crate) jump_pending: bool, + pub(crate) _edited: Subscription, +} + +impl FindState { + /// `i/n` for the bar; `0/0` while nothing matches. + pub(crate) fn count_label(&self) -> String { + match self.current.filter(|_| !self.matches.is_empty()) { + Some(ix) => format!("{}/{}", ix + 1, self.matches.len()), + None => "0/0".to_string(), + } + } +} + +/// Every occurrence as `(row index, nth in row)`, given each row's match +/// count. +pub(crate) fn occurrences(counts: impl IntoIterator) -> Vec<(usize, usize)> { + counts + .into_iter() + .enumerate() + .flat_map(|(row, n)| (0..n).map(move |nth| (row, nth))) + .collect() +} + +/// Matches of `query` in a rendered markdown block, fragment by fragment the +/// way the painters wash them (a match never spans two fragments). +pub(crate) fn block_count(block: &Block, query: &str) -> usize { + let runs = |runs: &[InlineRun]| { + let text: String = runs.iter().map(|run| run.text.as_str()).collect(); + match_ranges(&text, query).len() + }; + match block { + Block::Paragraph { runs: r } | Block::Heading { runs: r, .. } => runs(r), + Block::CodeBlock { code, .. } => code + .lines() + .map(|line| match_ranges(line, query).len()) + .sum(), + Block::BlockQuote { children } => children.iter().map(|b| block_count(b, query)).sum(), + Block::List { items, .. } => items.iter().flatten().map(|b| block_count(b, query)).sum(), + Block::Table { header, rows, .. } => header + .iter() + .chain(rows.iter().flatten()) + .map(|cell| runs(cell)) + .sum(), + Block::Math { .. } | Block::Rule => 0, + } +} + +/// Byte range of `needle` in `text`, matched case-insensitively per char so the +/// returned offsets always fall on char boundaries. +pub(crate) fn char_range(text: &str, needle: &str) -> Option<(usize, usize)> { + let want: Vec = needle.trim().to_lowercase().chars().collect(); + want.first()?; + let chars: Vec<(usize, char)> = text.char_indices().collect(); + for (i, &(start, _)) in chars.iter().enumerate() { + let mut matched = true; + for (offset, expected) in want.iter().enumerate() { + match chars.get(i + offset) { + Some(&(_, found)) if found.to_lowercase().next() == Some(*expected) => {} + _ => { + matched = false; + break; + } + } + } + if matched { + let end = chars + .get(i + want.len()) + .map(|&(byte, _)| byte) + .unwrap_or(text.len()); + return Some((start, end)); + } + } + None +} + +/// Matches of `query` in `text` as byte ranges, left to right. +pub(crate) fn match_ranges(text: &str, query: &str) -> Vec> { + let mut ranges = Vec::new(); + let mut from = 0; + while from < text.len() { + let Some((start, end)) = char_range(&text[from..], query) else { + break; + }; + let (start, end) = (from + start, from + end); + if end <= start { + break; + } + ranges.push(start..end); + from = end; + } + ranges +} + +thread_local! { + /// The open find bar's query, read by the painters so matches can be + /// washed inline. Only one transcript is visible per window. + static ACTIVE_QUERY: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + /// The current occurrence: `(row id, nth match in that row)`. + static CURRENT: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + /// Matches handed out so far in the row being built, so each fragment + /// knows the in-row ordinal of its first match. + static ORDINAL: std::cell::RefCell<(String, usize)> = + const { std::cell::RefCell::new((String::new(), 0)) }; +} + +pub(crate) fn set_active_query(query: Option<&str>) { + ACTIVE_QUERY.with(|slot| { + *slot.borrow_mut() = query + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(str::to_owned); + }); +} + +pub(crate) fn active_query() -> Option { + ACTIVE_QUERY.with(|slot| slot.borrow().clone()) +} + +pub(crate) fn set_current(current: Option<(SharedString, usize)>) { + CURRENT.with(|slot| *slot.borrow_mut() = current); +} + +/// Restart in-row ordinals: call before building a row's elements. +pub(crate) fn begin_row(row: &str) { + ORDINAL.with(|slot| { + let mut slot = slot.borrow_mut(); + slot.0.clear(); + slot.0.push_str(row); + slot.1 = 0; + }); +} + +/// The active query's matches in one painted fragment of `row`, each flagged +/// when it is the current occurrence. Fragments must be built in document +/// order after [`begin_row`]. +// ponytail: ordinals follow construction order; text laid out lazily (link +// truncation) can mark the wrong match current, key ordinals per fragment if so. +pub(crate) fn washes(row: &str, text: &str) -> Vec<(std::ops::Range, bool)> { + let Some(query) = active_query() else { + return Vec::new(); + }; + let ranges = match_ranges(text, &query); + if ranges.is_empty() { + return Vec::new(); + } + let first = ORDINAL.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.0 != row { + slot.0 = row.to_owned(); + slot.1 = 0; + } + let first = slot.1; + slot.1 += ranges.len(); + first + }); + let current = CURRENT.with(|slot| { + slot.borrow() + .as_ref() + .filter(|(id, _)| id.as_ref() == row) + .map(|(_, nth)| *nth) + }); + ranges + .into_iter() + .enumerate() + .map(|(i, range)| (range, current == Some(first + i))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{char_range, match_ranges, occurrences}; + + #[test] + fn occurrences_expand_counts_in_row_order() { + assert_eq!(occurrences([1, 0, 2]), vec![(0, 0), (2, 0), (2, 1)]); + assert!(occurrences([0, 0]).is_empty()); + } + + #[test] + fn matches_keep_char_boundaries() { + assert_eq!(char_range("naïve MAIN", "main"), Some((9, 13))); + assert!(char_range("nothing", "main").is_none()); + assert_eq!(match_ranges("main and MAIN", "main").len(), 2); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b8f32499e8..ce84ecfa8c 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -35,6 +35,7 @@ pub mod edge_fade; pub mod rounded_clip; pub mod file_icons; pub mod files; +mod find; pub mod frost; mod glass; mod haptics; diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 4f7df0269b..c8f7aeff85 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -1505,6 +1505,8 @@ pub(super) fn flat_text_presented_element( .map(|original| original.offsets.clone()); let wash = inline_code_wash(theme); let sel_wash = selection_wash(theme); + let find = crate::find::washes(&opts.row_key, &flat.text); + let find_theme = theme.clone(); let underlay = canvas( |bounds, window, _| window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal), move |_, hitbox, window, _| { @@ -1521,6 +1523,7 @@ pub(super) fn flat_text_presented_element( )); } } + paint_find_washes(window, &layout, &find, &find_theme); if let Some(range) = super::selection::wash_range(surface, &sel_key) { let range = offsets .as_ref() @@ -1717,12 +1720,16 @@ pub(crate) fn selectable_text_element( text: SharedString, runs: Vec, wash: Hsla, + find: Option<(Vec<(Range, bool)>, Theme)>, ) -> AnyElement { let styled = StyledText::new(text.clone()).with_runs(runs); let layout = styled.layout().clone(); let underlay = canvas( |bounds, window, _| window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal), move |_, hitbox, window, _| { + if let Some((find, theme)) = &find { + paint_find_washes(window, &layout, find, theme); + } paint_text_selection_with_wash(window, hitbox, &key, &text, &layout, wash); }, ) @@ -1734,6 +1741,28 @@ pub(crate) fn selectable_text_element( .into_any_element() } +/// Find-in-thread washes under matched text; the current match is stronger. +pub(crate) fn paint_find_washes( + window: &mut Window, + layout: &gpui::TextLayout, + washes: &[(Range, bool)], + theme: &Theme, +) { + for (range, current) in washes { + let color = theme.accent.opacity(if *current { 0.6 } else { 0.25 }); + for rect in range_rects(layout, range, 1.0, 0.0) { + window.paint_quad(quad( + rect, + px(3.0), + color, + px(0.0), + gpui::transparent_black(), + BorderStyle::default(), + )); + } + } +} + fn code_line_selection_key(row_key: &str, code_ix: usize, line_ix: usize) -> std::sync::Arc { format!("{row_key}-code{code_ix}-line{line_ix}").into() } @@ -2782,7 +2811,13 @@ fn render_code_block_source_with_actions( .flex_none() } }) - .child(selectable_text_element(key, line.clone(), runs, sel_wash)), + .child(selectable_text_element( + key, + line.clone(), + runs, + sel_wash, + Some((crate::find::washes(&opts.row_key, line), theme.clone())), + )), ) })); diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 83988deb09..a74018910a 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -2625,7 +2625,7 @@ fn valid_highlight_ranges( .collect() } -fn search_match_ranges(text: &str, query: &str) -> Vec> { +pub(crate) fn search_match_ranges(text: &str, query: &str) -> Vec> { let folded = text.to_lowercase(); let mut original = Vec::with_capacity(folded.len()); for (start, ch) in text.char_indices() { diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 427709bba0..fc4b65db5f 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -1429,11 +1429,12 @@ pub enum ShortcutId { NextSession, PrevSession, ArchiveSession, + FindInThread, JumpSession(usize), } impl ShortcutId { - pub const ALL: [ShortcutId; 15 + JUMP_SLOTS] = [ + pub const ALL: [ShortcutId; 16 + JUMP_SLOTS] = [ ShortcutId::ToggleDictation, ShortcutId::CaptureAppshot, ShortcutId::RandomWallpaper, @@ -1449,6 +1450,7 @@ impl ShortcutId { ShortcutId::NextSession, ShortcutId::PrevSession, ShortcutId::ArchiveSession, + ShortcutId::FindInThread, ShortcutId::JumpSession(0), ShortcutId::JumpSession(1), ShortcutId::JumpSession(2), @@ -1482,6 +1484,7 @@ impl ShortcutId { ShortcutId::NextSession => "Next session or right pane tab", ShortcutId::PrevSession => "Previous session or right pane tab", ShortcutId::ArchiveSession => "Archive session", + ShortcutId::FindInThread => "Find in thread", ShortcutId::JumpSession(slot) => JUMP_LABELS.get(slot).copied().unwrap_or(""), } } @@ -1526,6 +1529,7 @@ impl ShortcutId { // Mod+A is the composer's Select all, so archiving takes the // shifted combo. ShortcutId::ArchiveSession => "mod-shift-a", + ShortcutId::FindInThread => "mod-f", ShortcutId::JumpSession(slot) => JUMP_DEFAULTS.get(slot).copied().unwrap_or(""), } } @@ -1560,6 +1564,7 @@ pub struct KeymapConfig { pub next_session: String, pub prev_session: String, pub archive_session: String, + pub find_in_thread: String, /// One combo per jump slot, in slot order. A list rather than nine fields: /// [`UiSettings::load`] discards the WHOLE file on a parse error, so a /// fixed-length array would let one malformed entry reset every unrelated @@ -1631,6 +1636,7 @@ impl Default for KeymapConfig { next_session: ShortcutId::NextSession.default_combo().into(), prev_session: ShortcutId::PrevSession.default_combo().into(), archive_session: ShortcutId::ArchiveSession.default_combo().into(), + find_in_thread: ShortcutId::FindInThread.default_combo().into(), jump_session: JUMP_DEFAULTS.iter().map(|c| (*c).to_string()).collect(), layout_neutral: true, } @@ -1655,6 +1661,7 @@ impl KeymapConfig { ShortcutId::NextSession => &self.next_session, ShortcutId::PrevSession => &self.prev_session, ShortcutId::ArchiveSession => &self.archive_session, + ShortcutId::FindInThread => &self.find_in_thread, ShortcutId::JumpSession(slot) => self .jump_session .get(slot) @@ -1680,6 +1687,7 @@ impl KeymapConfig { ShortcutId::NextSession => self.next_session = combo, ShortcutId::PrevSession => self.prev_session = combo, ShortcutId::ArchiveSession => self.archive_session = combo, + ShortcutId::FindInThread => self.find_in_thread = combo, ShortcutId::JumpSession(slot) => { if slot < JUMP_SLOTS { if self.jump_session.len() < JUMP_SLOTS { diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index 2546500df3..066f1aaa53 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -736,6 +736,7 @@ pub(super) fn group(id: ShortcutId) -> &'static str { | ShortcutId::NewSession | ShortcutId::NextSession | ShortcutId::PrevSession + | ShortcutId::FindInThread | ShortcutId::ArchiveSession => "Sessions", ShortcutId::JumpSession(_) => "Jump to session", } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 742848b3cb..2edb8df790 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -141,7 +141,8 @@ actions!( OpenSettings, NextSession, PrevSession, - ArchiveSession + ArchiveSession, + FindInThread ] ); @@ -510,6 +511,14 @@ pub fn apply_keymap( AddSpacePalette, None, ), + KeyBinding::new( + &valid_or_default( + &keymap.find_in_thread, + ShortcutId::FindInThread.default_combo(), + ), + FindInThread, + None, + ), // Fixed: ⌘K summons the command palette. // Pressing it again dismisses. KeyBinding::new(&platform_combo("mod-k"), ToggleCommandPalette, None), @@ -11833,6 +11842,7 @@ impl Shell { - 24.0, cx, ); + let find_field = self.render_find_field(window, cx); self.chat_dropzone("chat-dropzone", self.composer.clone(), cx) .debug_selector(|| "chat-dropzone".into()) .track_focus(&self.navigation_focus.main) @@ -11897,6 +11907,7 @@ impl Shell { }, ) .children(session_card) + .children(find_field) .when_some(harness_update_card, |column, chip| { // Home notices stay anchored to the window bottom, behind the // dock. Clip paint and hitboxes at the same measured terminal @@ -14137,6 +14148,12 @@ impl Render for Shell { .on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| { this.toggle_command_palette(window, cx); })) + .on_action(cx.listener(|this, _: &FindInThread, window, cx| { + if matches!(this.route, Route::Chat) && !this.overlay_owns_keyboard(cx) { + this.transcript + .update(cx, |transcript, cx| transcript.open_find(window, cx)); + } + })) .on_action(cx.listener(|this, _: &OpenModelPicker, window, cx| { if matches!(this.route, Route::Chat) && !this.overlay_owns_keyboard(cx) { let pickers = this.composer.read(cx).pickers().clone(); diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 82e9008031..97bd9c06c9 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -51,6 +51,9 @@ pub(super) struct CommandPalette { files_ready: Option, /// Content matches in the focused chat's workspace. content: WorkspaceQuery>, + /// Chats whose transcripts mention the query, from the engine's FTS index. + transcript_hits: Vec, + transcript_task: Option>, _search_events: Subscription, } @@ -192,10 +195,12 @@ pub(super) enum Tab { Threads, Commands, Files, + Messages, } impl Tab { - pub(super) const ALL: [Tab; 4] = [Tab::All, Tab::Threads, Tab::Commands, Tab::Files]; + pub(super) const ALL: [Tab; 5] = + [Tab::All, Tab::Threads, Tab::Messages, Tab::Commands, Tab::Files]; fn label(self) -> &'static str { match self { @@ -203,6 +208,7 @@ impl Tab { Self::Threads => "Threads", Self::Commands => "Commands", Self::Files => "Files", + Self::Messages => "Messages", } } @@ -231,6 +237,7 @@ enum Section { Commands, Files, InFiles, + InMessages, } impl Section { @@ -243,6 +250,7 @@ impl Section { Self::Commands => "Commands", Self::Files => "Files", Self::InFiles => "In Files", + Self::InMessages => "In messages", } } } @@ -291,6 +299,8 @@ enum RowKind { }, /// A line of a file in the focused chat's workspace. Content(WorkspaceContentMatch), + /// A chat whose transcript matches, with the matched snippet. + Message(zeron_proto::TranscriptSearchHit), } #[derive(Clone, Debug, PartialEq)] @@ -312,6 +322,7 @@ impl PaletteRow { let id = match &self.kind { RowKind::Command(command) => format!("command:{}", command.label()), RowKind::Chat(id) => format!("chat:{id}"), + RowKind::Message(hit) => format!("message:{}:{}", hit.chat_id, hit.snippet), RowKind::File { path, .. } => format!("file:{path}"), RowKind::Content(found) => { format!("content:{}:{}:{}", found.path, found.line, found.column) @@ -463,6 +474,7 @@ impl Shell { palette.scroll.set_offset(gpui::point(px(0.0), px(0.0))); } this.search_palette_workspace(cx); + this.search_transcripts(cx); cx.notify(); } }); @@ -480,6 +492,8 @@ impl Shell { files: WorkspaceQuery::default(), files_ready: None, content: WorkspaceQuery::default(), + transcript_hits: Vec::new(), + transcript_task: None, _search_events: events, }); // Load the default Files list (recently changed files) right away. @@ -508,6 +522,54 @@ impl Shell { } } + /// Fill the In messages section from the engine's transcript index. + /// Engines without the method answer with an error and it stays empty. + fn search_transcripts(&mut self, cx: &mut Context) { + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + let Some(palette) = self.command_palette.as_mut() else { + return; + }; + let query = palette.search.read(cx).text().trim().to_owned(); + palette.transcript_hits.clear(); + if query.is_empty() { + palette.transcript_task = None; + return; + } + palette.transcript_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(80)) + .await; + let request = zeron_proto::SearchTranscriptsRequest { + query: query.clone(), + limit: 50, + }; + let Ok(params) = serde_json::to_value(&request) else { + return; + }; + let Ok(hits) = engine + .client() + .call(zeron_rpc::methods::SEARCH_TRANSCRIPTS, params) + .await + .map(serde_json::from_value::>) + else { + return; + }; + let Ok(hits) = hits else { + return; + }; + let _ = this.update(cx, |this, cx| { + if let Some(palette) = this.command_palette.as_mut() + && palette.search.read(cx).text().trim() == query + { + palette.transcript_hits = hits; + cx.notify(); + } + }); + })); + } + /// Look the query up in the focused workspace: file names at once, /// contents once typing pauses. fn search_palette_workspace(&mut self, cx: &mut Context) { @@ -718,6 +780,17 @@ impl Shell { ) .with_status(palette.content.status()) }; + let in_messages = |limit| { + PaletteSection::new( + Section::InMessages, + palette + .transcript_hits + .iter() + .take(limit) + .cloned() + .map(RowKind::Message), + ) + }; let sections = match (palette.tab, query.is_empty()) { (Tab::All, true) => vec![ recent_files(ALL_TAB_LIMIT), @@ -726,6 +799,7 @@ impl Shell { ], (Tab::All, false) => vec![ threads(ALL_TAB_LIMIT), + in_messages(ALL_TAB_LIMIT), commands(Section::Commands), files(ALL_TAB_LIMIT), in_files(ALL_TAB_LIMIT), @@ -736,6 +810,8 @@ impl Shell { (Tab::Commands, false) => vec![commands(Section::Commands)], (Tab::Files, true) => vec![recent_files(FILES_TAB_LIMIT)], (Tab::Files, false) => vec![files(FILES_TAB_LIMIT), in_files(CONTENT_TAB_LIMIT)], + (Tab::Messages, true) => Vec::new(), + (Tab::Messages, false) => vec![in_messages(THREADS_TAB_LIMIT)], }; sections .into_iter() @@ -870,6 +946,11 @@ impl Shell { cx.notify(); return; } + let query = self + .command_palette + .as_ref() + .map(|palette| palette.search.read(cx).text().trim().to_owned()) + .unwrap_or_default(); self.close_command_palette(window, cx); match row.kind { RowKind::Command(Command::NewChat) => self.open_new_session(None, cx), @@ -878,6 +959,13 @@ impl Shell { RowKind::Command(Command::Theme(_)) => unreachable!(), RowKind::Command(Command::ArchiveThread) => self.archive_selected_chat(cx), RowKind::Chat(id) => self.open_chat(id, cx), + // Land in the chat with the same words found, as T3 does. + RowKind::Message(hit) => { + self.open_chat(hit.chat_id.clone(), cx); + self.transcript.update(cx, |transcript, cx| { + transcript.find_in_chat(hit.chat_id, query, cx) + }); + } RowKind::File { path, is_dir } => { self.open_palette_path(path, is_dir, None, window, cx) } @@ -1128,6 +1216,117 @@ impl Shell { cx, ) } + RowKind::Message(hit) => { + let state = self.state.read(cx); + let chat = state.chats.iter().find(|chat| chat.id == hit.chat_id)?; + let title: SharedString = + transcript::single_line(chat.title.as_deref().unwrap_or("New session")).into(); + // "project · device", as the thread rows name them. + let device = (!zeron_proto::is_cloud_device(&chat.device_id, "")) + .then(|| state.device_name(&chat.device_id)) + .flatten(); + let context = state + .space_for_chat(chat) + .map(|space| space.display_name()) + .into_iter() + .chain(device) + .collect::>() + .join(" · "); + let project = (!context.is_empty()).then(|| SharedString::from(context)); + let ago = + format_time_ago(chat.last_message_at.unwrap_or(chat.created_at), Utc::now()); + let time: SharedString = if ago == "now" { + ago.into() + } else { + format!("{ago} ago").into() + }; + let snippet: SharedString = transcript::single_line(&hit.snippet).into(); + // Matched words bold in the text colour, the rest muted. + let bold = popover::search_match_ranges(&snippet, query) + .into_iter() + .map(|range| { + let style = gpui::HighlightStyle { + color: Some(theme.text), + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }; + (range, style) + }) + .collect::>(); + let small = |text: SharedString| { + div() + .flex_none() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted) + .child(text) + }; + let target = row.clone(); + popover::menu_row(theme, active, format!("command-message-{ix}")) + .id(("command-message", ix)) + .rounded(px(popover::PALETTE_ITEM_RADIUS)) + .role(gpui::Role::Button) + .aria_label(SharedString::from(format!("{title}: {snippet}"))) + .items_start() + .py(px(6.0)) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_palette_row(target.clone(), window, cx) + })) + .child( + icon(icons::CHAT_ROUND_LINE) + .size(px(16.0)) + .flex_none() + .mt(px(1.0)) + .text_color(theme.text_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(2.0)) + // Title, project, time. + .child( + div() + .flex() + .items_center() + .gap(px(8.0)) + .child(div().min_w_0().truncate().child(title)) + .children( + project.map(|project| { + small(project).max_w(px(200.0)).truncate() + }), + ) + .child(div().flex_1()) + .child(small(time)), + ) + // The matched words in context. + .child( + div() + .min_w_0() + .flex() + .gap(px(4.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted) + .child( + div() + .flex_none() + .text_color(if hit.from_user { + theme.accent + } else { + crate::changes::add_color(theme) + }) + .child(if hit.from_user { "You:" } else { "Agent:" }), + ) + .child(div().min_w_0().truncate().child( + gpui::StyledText::new(snippet).with_highlights(bold), + )), + ), + ) + .into_any_element() + } }) } diff --git a/crates/ui/src/shell/session_info.rs b/crates/ui/src/shell/session_info.rs index db54ff75c9..2d75465009 100644 --- a/crates/ui/src/shell/session_info.rs +++ b/crates/ui/src/shell/session_info.rs @@ -430,6 +430,27 @@ impl Shell { (docked, reveal, reserve) } + /// The find field, in the slot above the session cards. + pub(super) fn render_find_field( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let bar = self + .transcript + .update(cx, |transcript, cx| transcript.render_find_bar(window, cx))?; + Some( + div() + .absolute() + .top(px(Theme::TITLEBAR_HEIGHT + 12.0)) + .right(px(SESSION_CARD_INSET)) + .w(px(SESSION_CARD_WIDTH)) + .occlude() + .child(bar) + .into_any_element(), + ) + } + /// The card, positioned within the main column; `None` while closed or /// with no chat selected. pub(super) fn render_session_info( @@ -447,6 +468,13 @@ impl Shell { } let theme = Theme::of(cx).for_popup(); let now = Utc::now(); + // An open find field takes the top slot; the cards stack below it. + let find_drop = if self.transcript.read(cx).find_open() { + crate::find::BAR_HEIGHT + SESSION_CARDS_GAP + } else { + 0.0 + }; + let max_height = max_height - find_drop; let (chat, device, glyph, presence, project, branch) = { let state = self.state.read(cx); let chat = state.selected_chat_row()?.clone(); @@ -617,7 +645,7 @@ impl Shell { .id("session-info") .debug_selector(|| "session-info".into()) .absolute() - .top(px(Theme::TITLEBAR_HEIGHT + 12.0 + dy)) + .top(px(Theme::TITLEBAR_HEIGHT + 12.0 + find_drop + dy)) .right(px(SESSION_CARD_INSET - dx)) // The lanes card scrolls inside what the session card leaves // it below the titlebar. diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index e0b6db1013..b4f2687311 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -33,17 +33,19 @@ use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use gpui::{ - AnyElement, Bounds, ClipboardItem, ContentMask, Context, Entity, ListAlignment, ListOffset, - ListScrollEvent, ListState, MouseButton, MouseMoveEvent, MouseUpEvent, ObjectFit, PathBuilder, - Pixels, Point, ScrollDelta, ScrollWheelEvent, SharedString, StyledImage as _, StyledText, - Subscription, Task, TextAlign, TextRun, Window, canvas, div, img, list, point, prelude::*, px, - size, + AnyElement, Bounds, ClipboardItem, ContentMask, Context, Entity, Focusable as _, ListAlignment, + ListOffset, ListScrollEvent, ListState, MouseButton, MouseMoveEvent, MouseUpEvent, ObjectFit, + PathBuilder, Pixels, Point, ScrollDelta, ScrollWheelEvent, SharedString, StyledImage as _, + StyledText, Subscription, Task, TextAlign, TextRun, Window, canvas, div, img, list, point, + prelude::*, px, size, }; use zeron_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry, SubagentStatus}; use zeron_proto::view::{AnswerLine, InputOutcome}; use zeron_proto::{HarnessId, ToolCall}; +use crate::composer::{ComposerInput, ComposerInputEvent}; +use crate::find::FindState; use crate::markdown::mermaid_cache::{self, MermaidCache}; use crate::markdown::parser::{ Block, BlockTree, IncrementalParser, InlineRun, InlineStyle, parse_full, @@ -3422,6 +3424,10 @@ pub struct Transcript { compact_fold_settle: Option>, highlights: HighlightStore, show_jump_button: bool, + /// The open find bar, or `None` while closed. + find: Option, + /// A find query to open once this chat attaches (global search result). + pending_find: Option<(String, String)>, /// Distance from the bottom at the last observation (wheel event or spring /// tick) — restick and escape are direction-aware /// (see [`Transcript::should_restick`]). @@ -3806,6 +3812,8 @@ impl Transcript { compact_fold_settle: None, highlights: HighlightStore::default(), show_jump_button: false, + find: None, + pending_find: None, last_scroll_distance: 0.0, pinned, input_escaped: false, @@ -5090,6 +5098,14 @@ impl Transcript { self.render_cache.borrow_mut().clear(); self.highlights.entries.clear(); self.copied_message = None; + self.find = None; + if let Some((chat_id, query)) = self.pending_find.take() { + if self.chat_id.as_deref() == Some(chat_id.as_str()) { + self.find_query_in_chat(&query, cx); + } else { + self.pending_find = Some((chat_id, query)); + } + } self.copied_message_clear = None; self.list.reset(0); self.pending_viewport = None; @@ -5540,6 +5556,7 @@ impl Transcript { self.spring_kick = true; } } + self.refresh_find(); cx.notify(); } @@ -7864,6 +7881,7 @@ impl Transcript { let Some(row) = self.rows.get(ix).cloned() else { return gpui::Empty.into_any_element(); }; + crate::find::begin_row(&row.id); self.rendered_rows.insert(row.id.clone()); let theme = Theme::of(cx).clone(); let workspace_root = { @@ -9404,9 +9422,11 @@ fn user_bubble_text( .collect(); let sel_key: std::sync::Arc = format!("{row_id}:u").into(); let sel_theme = theme.clone(); + let find = crate::find::washes(row_id, &text); let underlay = canvas( |bounds, window, _| window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal), move |_, hitbox, window, cx| { + render::paint_find_washes(window, &layout, &find, &sel_theme); for (span, icon) in mentions.iter().zip(&chip_icons) { // Centered on the label like the composer's chips. let label = crate::composer::chip_pad_ranges(&span.range)[0].end; @@ -10050,6 +10070,7 @@ fn detail_body( text, runs, theme.selection, + None, )) }; let plain = |text: &SharedString, color: gpui::Hsla| { @@ -10970,6 +10991,269 @@ fn entry_fingerprint(entry: &SessionMessageEntry, pending: bool) -> u64 { fnv1a(&acc) } +/// Matches of `query` a row paints: a user row's text, or its markdown block. +fn row_find_count(row: &Row, query: &str) -> usize { + match &row.kind { + RowKind::User { text, .. } => crate::find::match_ranges(text, query).len(), + RowKind::Markdown { tree, block_ix } | RowKind::LiveMarkdown { tree, block_ix } => tree + .blocks + .get(*block_ix) + .map_or(0, |top| crate::find::block_count(&top.block, query)), + _ => 0, + } +} + +impl Transcript { + /// Open the find bar over the loaded transcript, or refocus it when it is + /// already open. + pub(crate) fn open_find(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(find) = self.find.as_mut() { + find.focus_pending = true; + cx.notify(); + return; + } + self.start_find(window.focused(cx), cx); + } + + /// Open the find bar on `query` in `chat_id`: now when it is already + /// shown, else once it attaches. + pub(crate) fn find_in_chat(&mut self, chat_id: String, query: String, cx: &mut Context) { + if self.chat_id.as_deref() == Some(chat_id.as_str()) { + self.find_query_in_chat(&query, cx); + } else { + self.pending_find = Some((chat_id, query)); + } + } + + fn find_query_in_chat(&mut self, query: &str, cx: &mut Context) { + if self.find.is_none() { + self.start_find(None, cx); + } + let Some(find) = self.find.as_mut() else { + return; + }; + find.focus_pending = true; + find.jump_pending = true; + find.input.update(cx, |input, cx| input.set_text(query, cx)); + self.set_find_query(query.to_owned(), cx); + } + + fn start_find(&mut self, previous_focus: Option, cx: &mut Context) { + let input = cx.new(|cx| { + ComposerInput::with_context("Find in thread…", "PaletteSearch", cx) + .with_single_line() + .with_text_metrics(13.0, 18.0) + }); + let edited = cx.subscribe( + &input, + |this: &mut Self, input, event: &ComposerInputEvent, cx| { + if matches!(event, ComposerInputEvent::Edited) { + let query = input.read(cx).text().to_string(); + this.set_find_query(query, cx); + } + }, + ); + self.find = Some(FindState { + input, + query: String::new(), + matches: Vec::new(), + current: None, + previous_focus, + focus_pending: true, + jump_pending: false, + _edited: edited, + }); + crate::find::set_active_query(Some("")); + cx.notify(); + } + + /// Close the find bar and hand focus back to whatever held it before. + fn close_find(&mut self, window: &mut Window, cx: &mut Context) { + let Some(find) = self.find.take() else { + return; + }; + crate::find::set_active_query(None); + crate::find::set_current(None); + if let Some(focus) = find.previous_focus { + window.focus(&focus, cx); + } + cx.notify(); + } + + /// Re-run the search after a query edit and land on the first match. + fn set_find_query(&mut self, query: String, cx: &mut Context) { + let Some(find) = self.find.as_mut() else { + return; + }; + find.query = query; + find.current = None; + crate::find::set_active_query(Some(&find.query)); + self.refresh_find(); + if let Some(row_ix) = self.sync_find_current() { + if let Some(find) = self.find.as_mut() { + find.jump_pending = false; + } + self.jump_to_find_row(row_ix); + } + cx.notify(); + } + + /// Advance to the next (`forward`) or previous match, wrapping around. + fn step_find(&mut self, forward: bool, cx: &mut Context) { + { + let Some(find) = self.find.as_mut() else { + return; + }; + let len = find.matches.len(); + if len == 0 { + return; + } + let current = find.current.unwrap_or(if forward { len - 1 } else { 0 }); + let next = if forward { + (current + 1) % len + } else { + (current + len - 1) % len + }; + find.current = Some(next); + } + if let Some(row_ix) = self.sync_find_current() { + self.jump_to_find_row(row_ix); + } + cx.notify(); + } + + /// Scroll a matched row near the viewport top, clearing the find bar. + fn jump_to_find_row(&mut self, ix: usize) { + self.stop_automatic_scrolling(); + self.list.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + } + + /// Recompute matches over newly loaded or changed rows, keeping the match + /// cursor inside the result set. + fn refresh_find(&mut self) { + let Some(find) = self.find.as_mut() else { + return; + }; + let query = find.query.trim(); + find.matches = + crate::find::occurrences(self.rows.iter().map(|row| row_find_count(row, query))); + find.current = match find.current { + Some(ix) if !find.matches.is_empty() => Some(ix.min(find.matches.len() - 1)), + _ => (!find.matches.is_empty()).then_some(0), + }; + let jump = !find.matches.is_empty() && std::mem::take(&mut find.jump_pending); + if let Some(row_ix) = self.sync_find_current().filter(|_| jump) { + self.jump_to_find_row(row_ix); + } + } + + /// Publish the current occurrence to the painters; its row index. + fn sync_find_current(&self) -> Option { + let current = self.find.as_ref().and_then(|find| { + let (row_ix, nth) = *find.matches.get(find.current?)?; + Some((row_ix, self.rows.get(row_ix)?.id.clone(), nth)) + }); + crate::find::set_current(current.as_ref().map(|(_, id, nth)| (id.clone(), *nth))); + current.map(|(row_ix, _, _)| row_ix) + } + + pub(crate) fn find_open(&self) -> bool { + self.find.is_some() + } + + /// The find field, unpositioned: the shell stacks it above the session + /// cards in the chat column's top-right corner. + pub(crate) fn render_find_bar( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let (input, count) = { + let find = self.find.as_mut()?; + if std::mem::take(&mut find.focus_pending) { + let input = find.input.clone(); + window.focus(&input.read(cx).focus_handle(cx), cx); + } + (find.input.clone(), find.count_label()) + }; + let theme = Theme::of(cx).for_popup(); + let icon_button = |id: &'static str, glyph: &'static str| { + div() + .id(id) + .flex_none() + .size(px(24.0)) + .rounded(px(6.0)) + .flex() + .items_center() + .justify_center() + .hover(|el| el.bg(theme.surface_raised_hover)) + .child( + crate::icons::icon(glyph) + .size(px(14.0)) + .text_color(theme.text_muted), + ) + }; + let bar = div() + .id("transcript-find") + .key_context("FindInThread") + .h(px(crate::find::BAR_HEIGHT)) + .w_full() + .flex() + .items_center() + .gap(px(6.0)) + .pl(px(12.0)) + .pr(px(6.0)) + .rounded(px(crate::popover::CARD_RADIUS)) + .border_1() + .border_color(theme.border) + .bg(crate::popover::surface_bg(&theme)) + .when(!theme.is_frost(), |el| el.shadow_lg()) + .text_color(theme.text) + .child(crate::popover::palette_search_icon(&theme)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(crate::typography::ui_rems(13.0)) + .child(input), + ) + .child( + div() + .flex_none() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted) + .child(count), + ) + .child( + icon_button("find-prev", crate::icons::ALT_ARROW_UP) + .on_click(cx.listener(|this, _, _, cx| this.step_find(false, cx))), + ) + .child( + icon_button("find-next", crate::icons::ALT_ARROW_DOWN) + .on_click(cx.listener(|this, _, _, cx| this.step_find(true, cx))), + ) + .child( + icon_button("find-close", crate::icons::CLOSE) + .on_click(cx.listener(|this, _, window, cx| this.close_find(window, cx))), + ) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { + match event.keystroke.key.as_str() { + "enter" => this.step_find(!event.keystroke.modifiers.shift, cx), + "escape" => this.close_find(window, cx), + _ => return, + } + cx.stop_propagation(); + })); + Some( + crate::frost::frosted(crate::popover::CARD_RADIUS, crate::frost::MENU_BLUR, bar) + .into_any_element(), + ) + } +} + impl Render for Transcript { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if record_view_frame("transcript") { diff --git a/docs/screenshots/find-in-thread/find-bar-dark.png b/docs/screenshots/find-in-thread/find-bar-dark.png new file mode 100644 index 0000000000..f79fe4834d Binary files /dev/null and b/docs/screenshots/find-in-thread/find-bar-dark.png differ diff --git a/docs/screenshots/find-in-thread/palette-messages-dark.png b/docs/screenshots/find-in-thread/palette-messages-dark.png new file mode 100644 index 0000000000..a253b9ee5e Binary files /dev/null and b/docs/screenshots/find-in-thread/palette-messages-dark.png differ