Persist unsent draft prompt across Zed restarts (#49541)
Store the user's unsent message editor text in DbThread so it survives quitting and reloading Zed. The draft flows through Thread → AcpThread → AcpThreadView on load, and back via a debounced observer on the message editor for saves. Currently works for native Zed agents only; external ACP agents will pick this up once general ACP history persistence lands. ## Changes - **`DbThread`** / **`Thread`**: New `draft_prompt: Option<String>` field, included in `to_db()`/`from_db()` - **`AcpThread`**: Bridge field with getter/setter, populated during `register_session()` - **`NativeAgent::save_thread()`**: Copies draft from `AcpThread` → `Thread` before persisting - **`AcpThreadView`**: Restores draft into `MessageEditor` on load; syncs editor text → `AcpThread` via observer; debounced (500ms) Thread notify triggers DB save Co-authored-by: Anthony Eid <anthony@zed.dev> Co-authored-by: Mikayla Maki <mikayla@zed.dev>
This commit is contained in:
1 parent
8a537c7211
commit
f19e4e0539
7 files changed
+133
-8
No files matched your search
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user