Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:08:30.966Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

draft_prompt_store.rs

226 lines · 7.5 KB · rust
1//! Per-thread draft prompt persistence and display label rendering.
2//!
3//! Drafts are persisted in the thread metadata store with `session_id: None`,
4//! but their unsent prompt text is kept separately here so we don't have to
5//! plumb draft-prompt storage through the native agent's thread database.
6//!
7//! The display-label helpers ([`display_label_for_draft`] and friends) live
8//! alongside the storage so the sidebar's preview rendering can't drift from
9//! the format we persist.
10
11use agent::OMEGA_AGENT_ID;
12use agent_client_protocol::schema::v1 as acp;
13use anyhow::Context as _;
14use db::kvp::KeyValueStore;
15use gpui::{App, AppContext as _, Entity, Task};
16use itertools::Itertools;
17use project::AgentId;
18use ui::SharedString;
19use util::ResultExt as _;
20use workspace::Workspace;
21
22use crate::AgentPanel;
23use crate::thread_metadata_store::ThreadId;
24
25const NAMESPACE: &str = "agent_draft_prompts";
26
27/// Maximum length (in characters) of a draft label rendered in the sidebar.
28const MAX_LABEL_CHARS: usize = 250;
29
30pub fn read(thread_id: ThreadId, cx: &App) -> Option<Vec<acp::ContentBlock>> {
31    let kvp = KeyValueStore::global(cx);
32    let raw = kvp
33        .scoped(NAMESPACE)
34        .read(&thread_id.to_key_string())
35        .log_err()
36        .flatten()?;
37    serde_json::from_str(&raw).log_err()
38}
39
40pub fn write(
41    thread_id: ThreadId,
42    prompt: &[acp::ContentBlock],
43    cx: &App,
44) -> Task<anyhow::Result<()>> {
45    let kvp = KeyValueStore::global(cx);
46    let key = thread_id.to_key_string();
47    let payload = match serde_json::to_string(prompt).context("serializing draft prompt") {
48        Ok(payload) => payload,
49        Err(err) => return Task::ready(Err(err)),
50    };
51    cx.background_spawn(async move { kvp.scoped(NAMESPACE).write(key, payload).await })
52}
53
54pub fn delete(thread_id: ThreadId, cx: &App) -> Task<anyhow::Result<()>> {
55    let kvp = KeyValueStore::global(cx);
56    let key = thread_id.to_key_string();
57    cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await })
58}
59
60pub fn draft_has_user_content<'a>(
61    thread_id: ThreadId,
62    workspaces: impl IntoIterator<Item = &'a Entity<Workspace>>,
63    cx: &App,
64) -> bool {
65    let mut found_live_copy = false;
66    for blocks in workspaces
67        .into_iter()
68        .filter_map(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
69        .filter_map(|panel| {
70            panel
71                .read(cx)
72                .draft_prompt_blocks_if_in_memory(thread_id, cx)
73        })
74    {
75        found_live_copy = true;
76        if blocks_have_user_content(&blocks) {
77            return true;
78        }
79    }
80
81    if found_live_copy {
82        false
83    } else {
84        read(thread_id, cx).is_some_and(|blocks| blocks_have_user_content(&blocks))
85    }
86}
87
88fn blocks_have_user_content(blocks: &[acp::ContentBlock]) -> bool {
89    blocks.iter().any(|block| match block {
90        acp::ContentBlock::Text(text) => !text.text.trim().is_empty(),
91        _ => true,
92    })
93}
94
95/// Rewrites `[@Something](scheme://...)` mention links as `@Something` so the
96/// sidebar's draft-title preview doesn't show raw markdown link syntax.
97pub fn clean_mention_links(input: &str) -> String {
98    let mut result = String::with_capacity(input.len());
99    let mut remaining = input;
100
101    while let Some(start) = remaining.find("[@") {
102        result.push_str(&remaining[..start]);
103        let after_bracket = &remaining[start + 1..];
104        if let Some(close_bracket) = after_bracket.find("](") {
105            let mention = &after_bracket[..close_bracket];
106            let after_link_start = &after_bracket[close_bracket + 2..];
107            if let Some(close_paren) = after_link_start.find(')') {
108                result.push_str(mention);
109                remaining = &after_link_start[close_paren + 1..];
110                continue;
111            }
112        }
113        result.push_str("[@");
114        remaining = &remaining[start + 2..];
115    }
116    result.push_str(remaining);
117    result
118}
119
120/// Collapses whitespace and truncates raw editor text for display as a draft
121/// label in the sidebar.
122pub fn truncate_draft_label(raw: &str) -> Option<SharedString> {
123    let first_line = raw.lines().next().unwrap_or("");
124    let cleaned = clean_mention_links(first_line);
125    let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
126    if text.is_empty() {
127        return None;
128    }
129    if let Some((truncate_at, _)) = text.char_indices().nth(MAX_LABEL_CHARS) {
130        text.truncate(truncate_at);
131    }
132    Some(text.into())
133}
134
135/// Renders a draft thread's display label for sidebar rows and similar
136/// preview UI.
137///
138/// Prefers the live message editor's text (when the thread's
139/// `ConversationView` is loaded in the workspace's `AgentPanel`), and
140/// otherwise falls back to the persisted draft prompt in the kvp store so
141/// drafts restored from disk — but not yet opened — still show a meaningful
142/// title instead of the generic default.
143pub fn display_label_for_draft(
144    workspace: Option<&Entity<Workspace>>,
145    thread_id: ThreadId,
146    cx: &App,
147) -> Option<SharedString> {
148    let in_memory = workspace
149        .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
150        .and_then(|panel| panel.read(cx).editor_text_if_in_memory(thread_id, cx));
151    match in_memory {
152        Some(Some(raw)) => return truncate_draft_label(&raw),
153        Some(None) => return None,
154        None => {}
155    }
156
157    let blocks = read(thread_id, cx)?;
158    let raw = blocks
159        .iter()
160        .filter_map(|block| match block {
161            acp::ContentBlock::Text(text) => Some(text.text.as_str()),
162            acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()),
163            _ => None,
164        })
165        .join(" ");
166    truncate_draft_label(&raw)
167}
168
169pub fn empty_draft_placeholder_label(
170    workspace: Option<&Entity<Workspace>>,
171    agent_id: &AgentId,
172    cx: &App,
173) -> SharedString {
174    let agent_name = if agent_id.as_ref() == OMEGA_AGENT_ID.as_ref() {
175        SharedString::from(OMEGA_AGENT_ID.to_string())
176    } else {
177        workspace
178            .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone())
179            .and_then(|store| store.read(cx).agent_display_name(agent_id))
180            .unwrap_or_else(|| SharedString::from(agent_id.to_string()))
181    };
182
183    format!("New {} Thread", agent_name).into()
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_clean_mention_links() {
192        // Simple mention link.
193        assert_eq!(
194            clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
195            "check @Button.tsx"
196        );
197
198        // Multiple mention links on one line.
199        assert_eq!(
200            clean_mention_links("look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"),
201            "look at @foo.rs and @bar.rs"
202        );
203
204        // Plain text without mentions is preserved.
205        assert_eq!(
206            clean_mention_links("plain text with no mentions"),
207            "plain text with no mentions"
208        );
209
210        // Broken syntax (no closing bracket) is left alone.
211        assert_eq!(
212            clean_mention_links("broken [@mention without closing"),
213            "broken [@mention without closing"
214        );
215
216        // Non-`@` markdown links are not touched.
217        assert_eq!(
218            clean_mention_links("see [docs](https://example.com)"),
219            "see [docs](https://example.com)"
220        );
221
222        // Empty input.
223        assert_eq!(clean_mention_links(""), "");
224    }
225}
226
Served at tenant.openagents/omega Member data and write actions are omitted.