//! Per-thread draft prompt persistence and display label rendering. //! //! Drafts are persisted in the thread metadata store with `session_id: None`, //! but their unsent prompt text is kept separately here so we don't have to //! plumb draft-prompt storage through the native agent's thread database. //! //! The display-label helpers ([`display_label_for_draft`] and friends) live //! alongside the storage so the sidebar's preview rendering can't drift from //! the format we persist. use agent::ZED_AGENT_ID; use agent_client_protocol::schema::v1 as acp; use anyhow::Context as _; use db::kvp::KeyValueStore; use gpui::{App, AppContext as _, Entity, Task}; use itertools::Itertools; use project::AgentId; use ui::SharedString; use util::ResultExt as _; use workspace::Workspace; use crate::AgentPanel; use crate::thread_metadata_store::ThreadId; const NAMESPACE: &str = "agent_draft_prompts"; /// Maximum length (in characters) of a draft label rendered in the sidebar. const MAX_LABEL_CHARS: usize = 250; pub fn read(thread_id: ThreadId, cx: &App) -> Option> { let kvp = KeyValueStore::global(cx); let raw = kvp .scoped(NAMESPACE) .read(&thread_id.to_key_string()) .log_err() .flatten()?; serde_json::from_str(&raw).log_err() } pub fn write( thread_id: ThreadId, prompt: &[acp::ContentBlock], cx: &App, ) -> Task> { let kvp = KeyValueStore::global(cx); let key = thread_id.to_key_string(); let payload = match serde_json::to_string(prompt).context("serializing draft prompt") { Ok(payload) => payload, Err(err) => return Task::ready(Err(err)), }; cx.background_spawn(async move { kvp.scoped(NAMESPACE).write(key, payload).await }) } pub fn delete(thread_id: ThreadId, cx: &App) -> Task> { let kvp = KeyValueStore::global(cx); let key = thread_id.to_key_string(); cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await }) } pub fn draft_has_user_content<'a>( thread_id: ThreadId, workspaces: impl IntoIterator>, cx: &App, ) -> bool { let mut found_live_copy = false; for blocks in workspaces .into_iter() .filter_map(|workspace| workspace.read(cx).panel::(cx)) .filter_map(|panel| { panel .read(cx) .draft_prompt_blocks_if_in_memory(thread_id, cx) }) { found_live_copy = true; if blocks_have_user_content(&blocks) { return true; } } if found_live_copy { false } else { read(thread_id, cx).is_some_and(|blocks| blocks_have_user_content(&blocks)) } } fn blocks_have_user_content(blocks: &[acp::ContentBlock]) -> bool { blocks.iter().any(|block| match block { acp::ContentBlock::Text(text) => !text.text.trim().is_empty(), _ => true, }) } /// Rewrites `[@Something](scheme://...)` mention links as `@Something` so the /// sidebar's draft-title preview doesn't show raw markdown link syntax. pub fn clean_mention_links(input: &str) -> String { let mut result = String::with_capacity(input.len()); let mut remaining = input; while let Some(start) = remaining.find("[@") { result.push_str(&remaining[..start]); let after_bracket = &remaining[start + 1..]; if let Some(close_bracket) = after_bracket.find("](") { let mention = &after_bracket[..close_bracket]; let after_link_start = &after_bracket[close_bracket + 2..]; if let Some(close_paren) = after_link_start.find(')') { result.push_str(mention); remaining = &after_link_start[close_paren + 1..]; continue; } } result.push_str("[@"); remaining = &remaining[start + 2..]; } result.push_str(remaining); result } /// Collapses whitespace and truncates raw editor text for display as a draft /// label in the sidebar. pub fn truncate_draft_label(raw: &str) -> Option { let first_line = raw.lines().next().unwrap_or(""); let cleaned = clean_mention_links(first_line); let mut text: String = cleaned.split_whitespace().collect::>().join(" "); if text.is_empty() { return None; } if let Some((truncate_at, _)) = text.char_indices().nth(MAX_LABEL_CHARS) { text.truncate(truncate_at); } Some(text.into()) } /// Renders a draft thread's display label for sidebar rows and similar /// preview UI. /// /// Prefers the live message editor's text (when the thread's /// `ConversationView` is loaded in the workspace's `AgentPanel`), and /// otherwise falls back to the persisted draft prompt in the kvp store so /// drafts restored from disk — but not yet opened — still show a meaningful /// title instead of the generic default. pub fn display_label_for_draft( workspace: Option<&Entity>, thread_id: ThreadId, cx: &App, ) -> Option { let in_memory = workspace .and_then(|ws| ws.read(cx).panel::(cx)) .and_then(|panel| panel.read(cx).editor_text_if_in_memory(thread_id, cx)); match in_memory { Some(Some(raw)) => return truncate_draft_label(&raw), Some(None) => return None, None => {} } let blocks = read(thread_id, cx)?; let raw = blocks .iter() .filter_map(|block| match block { acp::ContentBlock::Text(text) => Some(text.text.as_str()), acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()), _ => None, }) .join(" "); truncate_draft_label(&raw) } pub fn empty_draft_placeholder_label( workspace: Option<&Entity>, agent_id: &AgentId, cx: &App, ) -> SharedString { let agent_name = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { SharedString::from(ZED_AGENT_ID.to_string()) } else { workspace .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()) .and_then(|store| store.read(cx).agent_display_name(agent_id)) .unwrap_or_else(|| SharedString::from(agent_id.to_string())) }; format!("New {} Thread", agent_name).into() } #[cfg(test)] mod tests { use super::*; #[test] fn test_clean_mention_links() { // Simple mention link. assert_eq!( clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), "check @Button.tsx" ); // Multiple mention links on one line. assert_eq!( clean_mention_links("look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"), "look at @foo.rs and @bar.rs" ); // Plain text without mentions is preserved. assert_eq!( clean_mention_links("plain text with no mentions"), "plain text with no mentions" ); // Broken syntax (no closing bracket) is left alone. assert_eq!( clean_mention_links("broken [@mention without closing"), "broken [@mention without closing" ); // Non-`@` markdown links are not touched. assert_eq!( clean_mention_links("see [docs](https://example.com)"), "see [docs](https://example.com)" ); // Empty input. assert_eq!(clean_mention_links(""), ""); } }