Skip to repository content226 lines · 7.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:08:30.966Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
draft_prompt_store.rs
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