Detailed changes
@@ -970,6 +970,8 @@ pub struct AcpThread {
pending_terminal_output: HashMap<acp::TerminalId, Vec<Vec<u8>>>,
pending_terminal_exit: HashMap<acp::TerminalId, acp::TerminalExitStatus>,
had_error: bool,
+ /// The user's unsent prompt text, persisted so it can be restored when reloading the thread.
+ draft_prompt: Option<Vec<acp::ContentBlock>>,
}
impl From<&AcpThread> for ActionLogTelemetry {
@@ -1207,6 +1209,7 @@ impl AcpThread {
pending_terminal_output: HashMap::default(),
pending_terminal_exit: HashMap::default(),
had_error: false,
+ draft_prompt: None,
}
}
@@ -1218,6 +1221,14 @@ impl AcpThread {
self.prompt_capabilities.clone()
}
+ pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
+ self.draft_prompt.as_deref()
+ }
+
+ pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
+ self.draft_prompt = prompt;
+ }
+
pub fn connection(&self) -> &Rc<dyn AgentConnection> {
&self.connection
}
@@ -351,11 +351,12 @@ impl NativeAgent {
let session_id = thread.id().clone();
let parent_session_id = thread.parent_thread_id();
let title = thread.title();
+ let draft_prompt = thread.draft_prompt().map(Vec::from);
let project = thread.project.clone();
let action_log = thread.action_log.clone();
let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone();
let acp_thread = cx.new(|cx| {
- acp_thread::AcpThread::new(
+ let mut acp_thread = acp_thread::AcpThread::new(
parent_session_id,
title,
connection,
@@ -364,7 +365,9 @@ impl NativeAgent {
session_id.clone(),
prompt_capabilities_rx,
cx,
- )
+ );
+ acp_thread.set_draft_prompt(draft_prompt);
+ acp_thread
});
let registry = LanguageModelRegistry::read_global(cx);
@@ -844,9 +847,7 @@ impl NativeAgent {
return;
}
- let database_future = ThreadsDatabase::connect(cx);
- let (id, db_thread) =
- thread.update(cx, |thread, cx| (thread.id().clone(), thread.to_db(cx)));
+ let id = thread.read(cx).id().clone();
let Some(session) = self.sessions.get_mut(&id) else {
return;
};
@@ -860,6 +861,12 @@ impl NativeAgent {
.collect::<Vec<_>>(),
);
+ let draft_prompt = session.acp_thread.read(cx).draft_prompt().map(Vec::from);
+ let database_future = ThreadsDatabase::connect(cx);
+ let db_thread = thread.update(cx, |thread, cx| {
+ thread.set_draft_prompt(draft_prompt);
+ thread.to_db(cx)
+ });
let thread_store = self.thread_store.clone();
session.pending_save = cx.spawn(async move |_, cx| {
let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else {
@@ -2571,6 +2578,18 @@ mod internal_tests {
cx.run_until_parked();
+ // Set a draft prompt with rich content blocks before saving.
+ let draft_blocks = vec![
+ acp::ContentBlock::Text(acp::TextContent::new("Check out ")),
+ acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())),
+ acp::ContentBlock::Text(acp::TextContent::new(" please")),
+ ];
+ acp_thread.update(cx, |thread, _cx| {
+ thread.set_draft_prompt(Some(draft_blocks.clone()));
+ });
+ thread.update(cx, |_thread, cx| cx.notify());
+ cx.run_until_parked();
+
// Close the session so it can be reloaded from disk.
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
@@ -2608,6 +2627,11 @@ mod internal_tests {
"}
)
});
+
+ // Ensure the draft prompt with rich content blocks survived the round-trip.
+ acp_thread.read_with(cx, |thread, _| {
+ assert_eq!(thread.draft_prompt(), Some(draft_blocks.as_slice()));
+ });
}
fn thread_entries(
@@ -64,6 +64,8 @@ pub struct DbThread {
pub thinking_enabled: bool,
#[serde(default)]
pub thinking_effort: Option<String>,
+ #[serde(default)]
+ pub draft_prompt: Option<Vec<acp::ContentBlock>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -105,6 +107,7 @@ impl SharedThread {
speed: None,
thinking_enabled: false,
thinking_effort: None,
+ draft_prompt: None,
}
}
@@ -282,6 +285,7 @@ impl DbThread {
speed: None,
thinking_enabled: false,
thinking_effort: None,
+ draft_prompt: None,
})
}
}
@@ -632,6 +636,7 @@ mod tests {
speed: None,
thinking_enabled: false,
thinking_effort: None,
+ draft_prompt: None,
}
}
@@ -715,6 +720,22 @@ mod tests {
);
}
+ #[test]
+ fn test_draft_prompt_defaults_to_none() {
+ let json = r#"{
+ "title": "Old Thread",
+ "messages": [],
+ "updated_at": "2024-01-01T00:00:00Z"
+ }"#;
+
+ let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize");
+
+ assert!(
+ db_thread.draft_prompt.is_none(),
+ "Legacy threads without draft_prompt field should default to None"
+ );
+ }
+
#[gpui::test]
async fn test_subagent_context_roundtrips_through_save_load(cx: &mut TestAppContext) {
let database = ThreadsDatabase::new(cx.executor()).unwrap();
@@ -899,6 +899,8 @@ pub struct Thread {
imported: bool,
/// If this is a subagent thread, contains context about the parent
subagent_context: Option<SubagentContext>,
+ /// The user's unsent prompt text, persisted so it can be restored when reloading the thread.
+ draft_prompt: Option<Vec<acp::ContentBlock>>,
/// Weak references to running subagent threads for cancellation propagation
running_subagents: Vec<WeakEntity<Thread>>,
}
@@ -1014,6 +1016,7 @@ impl Thread {
file_read_times: HashMap::default(),
imported: false,
subagent_context: None,
+ draft_prompt: None,
running_subagents: Vec::new(),
}
}
@@ -1229,6 +1232,7 @@ impl Thread {
file_read_times: HashMap::default(),
imported: db_thread.imported,
subagent_context: db_thread.subagent_context,
+ draft_prompt: db_thread.draft_prompt,
running_subagents: Vec::new(),
}
}
@@ -1253,6 +1257,7 @@ impl Thread {
speed: self.speed,
thinking_enabled: self.thinking_enabled,
thinking_effort: self.thinking_effort.clone(),
+ draft_prompt: self.draft_prompt.clone(),
};
cx.background_spawn(async move {
@@ -1294,6 +1299,14 @@ impl Thread {
self.messages.is_empty() && self.title.is_none()
}
+ pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
+ self.draft_prompt.as_deref()
+ }
+
+ pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
+ self.draft_prompt = prompt;
+ }
+
pub fn model(&self) -> Option<&Arc<dyn LanguageModel>> {
self.model.as_ref()
}
@@ -145,6 +145,7 @@ mod tests {
speed: None,
thinking_enabled: false,
thinking_effort: None,
+ draft_prompt: None,
}
}
@@ -5,6 +5,7 @@ use gpui::{Corner, List};
use language_model::{LanguageModelEffortLevel, Speed};
use settings::update_settings_file;
use ui::{ButtonLike, SplitButton, SplitButtonStyle, Tab};
+use workspace::SERIALIZATION_THROTTLE_TIME;
use super::*;
@@ -239,6 +240,7 @@ pub struct ThreadView {
pub resumed_without_history: bool,
pub resume_thread_metadata: Option<AgentSessionInfo>,
pub _cancel_task: Option<Task<()>>,
+ _draft_save_task: Option<Task<()>>,
pub skip_queue_processing_count: usize,
pub user_interrupted_generation: bool,
pub can_fast_track_queue: bool,
@@ -345,6 +347,8 @@ impl ThreadView {
editor.set_message(blocks, window, cx);
}
}
+ } else if let Some(draft) = thread.read(cx).draft_prompt() {
+ editor.set_message(draft.to_vec(), window, cx);
}
editor
});
@@ -377,6 +381,38 @@ impl ThreadView {
Self::handle_message_editor_event,
));
+ subscriptions.push(cx.observe(&message_editor, |this, editor, cx| {
+ let is_empty = editor.read(cx).text(cx).is_empty();
+ let draft_contents_task = if is_empty {
+ None
+ } else {
+ Some(editor.update(cx, |editor, cx| editor.draft_contents(cx)))
+ };
+ this._draft_save_task = Some(cx.spawn(async move |this, cx| {
+ let draft = if let Some(task) = draft_contents_task {
+ let blocks = task.await.ok().filter(|b| !b.is_empty());
+ blocks
+ } else {
+ None
+ };
+ this.update(cx, |this, cx| {
+ this.thread.update(cx, |thread, _cx| {
+ thread.set_draft_prompt(draft);
+ });
+ })
+ .ok();
+ cx.background_executor()
+ .timer(SERIALIZATION_THROTTLE_TIME)
+ .await;
+ this.update(cx, |this, cx| {
+ if let Some(thread) = this.as_native_thread(cx) {
+ thread.update(cx, |_thread, cx| cx.notify());
+ }
+ })
+ .ok();
+ }));
+ }));
+
let recent_history_entries = history.read(cx).get_recent_sessions(3);
let mut this = Self {
@@ -427,6 +463,7 @@ impl ThreadView {
is_loading_contents: false,
new_server_version_available: None,
_cancel_task: None,
+ _draft_save_task: None,
skip_queue_processing_count: 0,
user_interrupted_generation: false,
can_fast_track_queue: false,
@@ -416,7 +416,27 @@ impl MessageEditor {
let text = self.editor.read(cx).text(cx);
let available_commands = self.available_commands.borrow().clone();
let agent_name = self.agent_name.clone();
+ let build_task = self.build_content_blocks(full_mention_content, cx);
+ cx.spawn(async move |_, _cx| {
+ Self::validate_slash_commands(&text, &available_commands, &agent_name)?;
+ build_task.await
+ })
+ }
+
+ pub fn draft_contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
+ let build_task = self.build_content_blocks(false, cx);
+ cx.spawn(async move |_, _cx| {
+ let (blocks, _tracked_buffers) = build_task.await?;
+ Ok(blocks)
+ })
+ }
+
+ fn build_content_blocks(
+ &self,
+ full_mention_content: bool,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
let contents = self
.mention_set
.update(cx, |store, cx| store.contents(full_mention_content, cx));
@@ -424,18 +444,16 @@ impl MessageEditor {
let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
cx.spawn(async move |_, cx| {
- Self::validate_slash_commands(&text, &available_commands, &agent_name)?;
-
let contents = contents.await?;
let mut all_tracked_buffers = Vec::new();
let result = editor.update(cx, |editor, cx| {
+ let text = editor.text(cx);
let (mut ix, _) = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.unwrap_or((0, '\0'));
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
- let text = editor.text(cx);
editor.display_map.update(cx, |map, cx| {
let snapshot = map.snapshot(cx);
for (crease_id, crease) in snapshot.crease_snapshot.creases() {