225 lines · 7.3 KB · Rust
RawDownload
//! 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<Vec<acp::ContentBlock>> {    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<anyhow::Result<()>> {    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<anyhow::Result<()>> {    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<Item = &'a Entity<Workspace>>,    cx: &App,) -> bool {    let mut found_live_copy = false;    for blocks in workspaces        .into_iter()        .filter_map(|workspace| workspace.read(cx).panel::<AgentPanel>(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<SharedString> {    let first_line = raw.lines().next().unwrap_or("");    let cleaned = clean_mention_links(first_line);    let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().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<Workspace>>,    thread_id: ThreadId,    cx: &App,) -> Option<SharedString> {    let in_memory = workspace        .and_then(|ws| ws.read(cx).panel::<AgentPanel>(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<Workspace>>,    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(""), "");    }}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t