Keep the composer responsive during Command Center session creation (#46077)

## Why

Creating a session from Command Center waits for configuration and server requests before displaying the composer. Scanning loaded agents also adds unnecessary round trips for a new session with no descendants.

## What changed

- Show an editable startup composer while loading settings, starting the thread, and attaching the new session.
- Transfer the draft into the new session, including pending paste state, and retain edits for a retry if setup fails.
- Keep session creation running when cancellation keys are pressed in the provisional composer.
- Skip descendant backfill and paused-goal resume prompts for new sessions.

GitOrigin-RevId: 8371a6c3edc09de6366253021eae99f7b60d78ef
This commit is contained in:
Eric Traut
2026-09-17 01:46:35 +00:00
committed by copyberry
parent 800d183e2d
commit 1e9564fb85
6 changed files with 238 additions and 73 deletions

View File

@@ -21,6 +21,7 @@ use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::chatwidget::ThreadInputStateRestoreMode;
use crate::startup_draft::StartupDraftPump;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadHistoryMode;
@@ -54,6 +55,7 @@ pub(super) struct AgentsOverviewState {
/// Keep new tasks subscribed and reusable until a first turn makes them resumable.
pub(super) blank_sessions: HashMap<ThreadId, crate::app_server_session::AppServerStartedThread>,
pub(super) input_states: HashMap<ThreadId, ThreadInputState>,
pub(super) new_session_draft: Option<Box<StartupDraftPump>>,
pub(super) dispatched_requests: HashMap<ThreadId, Vec<ServerRequest>>,
}
@@ -310,8 +312,10 @@ impl App {
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<AppRunControl> {
Box::pin(self.attach_agents_overview_thread(tui, app_server, thread_id, /*started*/ None))
.await
Box::pin(self.attach_agents_overview_thread(
tui, app_server, thread_id, /*started*/ None, /*startup_draft*/ None,
))
.await
}
async fn attach_agents_overview_thread(
@@ -320,10 +324,12 @@ impl App {
app_server: &mut AppServerSession,
root_thread_id: ThreadId,
started: Option<(Config, crate::app_server_session::AppServerStartedThread)>,
mut startup_draft: Option<&mut StartupDraftPump>,
) -> color_eyre::Result<AppRunControl> {
if self.windows_sandbox_blocks_thread_switch() {
return Ok(AppRunControl::Continue);
}
let is_new_session = started.is_some();
if self.current_displayed_thread_id() == Some(root_thread_id)
&& (!self.thread_unavailable(root_thread_id)
|| self.chat_widget.is_external_writer_view())
@@ -392,9 +398,12 @@ impl App {
.insert(active_thread_id, input_state);
}
let target_thread = match app_server
.thread_read(root_thread_id, /*include_turns*/ false)
.await
let target_thread = match StartupDraftPump::run_with_optional_draft(
startup_draft.as_deref_mut(),
tui,
app_server.thread_read(root_thread_id, /*include_turns*/ false),
)
.await
{
Ok(thread) => thread,
Err(error) => {
@@ -456,6 +465,7 @@ impl App {
&mut resume_config,
target_thread.cwd.as_path(),
Some(&target_thread),
/*startup_draft*/ None,
)
.await
{
@@ -553,7 +563,18 @@ impl App {
};
if !previous_running_thread_ids.is_empty() {
for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) {
if !self.discard_side_thread(app_server, side_thread_id).await {
let discarded = match startup_draft.as_deref_mut() {
Some(draft) => {
draft
.run_until(
tui,
self.discard_side_thread(app_server, side_thread_id),
)
.await?
}
None => self.discard_side_thread(app_server, side_thread_id).await,
};
if !discarded {
let _ = app_server.thread_unsubscribe(root_thread_id).await;
return Ok(AppRunControl::Continue);
}
@@ -580,7 +601,14 @@ impl App {
&& !previous_displayed_thread_id
.is_some_and(|id| self.agents_overview.blank_sessions.contains_key(&id))
{
self.shutdown_current_thread(app_server).await;
match startup_draft.as_deref_mut() {
Some(draft) => {
draft
.run_until(tui, self.shutdown_current_thread(app_server))
.await?
}
None => self.shutdown_current_thread(app_server).await,
}
}
// Explicit choices carry across cold resumes and new sessions.
self.runtime_approval_policy_override =
@@ -656,10 +684,13 @@ impl App {
.matches_config(&self.config))
.then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config));
}
if !self
.backfill_loaded_subagent_threads(app_server)
.await
.completed
// A new session has no descendants. Scanning every loaded thread here
// adds a serial round trip per agent before the composer can render.
if !is_new_session
&& !self
.backfill_loaded_subagent_threads(app_server)
.await
.completed
{
self.backfill_loaded_subagent_threads(app_server).await;
}
@@ -668,7 +699,12 @@ impl App {
&& thread_id != root_thread_id
&& Some(thread_id) != previous_displayed_thread_id
&& !self.agents_overview.blank_sessions.contains_key(&thread_id)
&& let Err(error) = app_server.thread_unsubscribe(thread_id).await
&& let Err(error) = StartupDraftPump::run_with_optional_draft(
startup_draft.as_deref_mut(),
tui,
app_server.thread_unsubscribe(thread_id),
)
.await
{
tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread");
}
@@ -705,7 +741,7 @@ impl App {
self.chat_widget.maybe_send_next_queued_input();
}
}
if !read_only {
if !read_only && !is_new_session {
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
.await;
}
@@ -738,6 +774,7 @@ impl App {
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
cwd: Option<AbsolutePathBuf>,
mut startup_draft: Option<&mut StartupDraftPump>,
) -> Option<(Config, Option<PathBuf>)> {
if self
.chat_widget
@@ -763,7 +800,13 @@ impl App {
|cwd| cwd.to_path_buf(),
)
};
let mut config = match self.rebuild_config_for_cwd(local_cwd).await {
let mut config = match StartupDraftPump::run_with_optional_draft(
startup_draft.as_deref_mut(),
tui,
self.rebuild_config_for_cwd(local_cwd),
)
.await
{
Ok(config) => config,
Err(error) => {
self.add_agents_overview_error(format!("Failed to load project settings: {error}"));
@@ -781,6 +824,7 @@ impl App {
&mut config,
&trust_cwd,
/*resumed_thread*/ None,
startup_draft.as_deref_mut(),
)
.await
.is_err()
@@ -812,10 +856,17 @@ impl App {
.or_else(|| app_server.remote_cwd_override())
.unwrap_or(Path::new(".")),
};
if let Some(draft) = startup_draft.as_deref_mut() {
draft.apply_config(&config);
}
let mut server_model_cleared = false;
match crate::config_update::read_effective_config_if_supported(
app_server.request_handle(),
defaults_cwd,
match StartupDraftPump::run_with_optional_draft(
startup_draft,
tui,
crate::config_update::read_effective_config_if_supported(
app_server.request_handle(),
defaults_cwd,
),
)
.await
{

View File

@@ -2,6 +2,9 @@
//! Worktrees start at the repository default branch and bind only the new session.
use super::*;
use crate::startup_draft::StartupDraftInitialScreen;
use crate::startup_draft::StartupDraftPump;
use crate::startup_draft::StartupDraftSessionAction;
/// Owns a freshly created checkout until the UI accepts its completion event.
/// Dropping a cancelled worker or undelivered event removes only a clean checkout.
@@ -39,13 +42,46 @@ impl App {
app_server: &mut AppServerSession,
cwd: Option<AbsolutePathBuf>,
) -> Result<AppRunControl> {
if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() {
return Ok(AppRunControl::Continue);
}
let previous_thread = self.current_displayed_thread_id();
let mut draft = self
.agents_overview
.new_session_draft
.take()
.unwrap_or_else(|| {
Box::new(StartupDraftPump::new(
tui,
StartupDraftInitialScreen::Composer,
StartupDraftSessionAction::NewFromCommandCenter,
))
});
let mut display_config = self.chat_widget.config_ref().clone();
if let Some(cwd) = cwd.as_ref() {
display_config.cwd = cwd.clone();
}
draft.apply_config(&display_config);
tui.terminal.clear()?;
// Keep the large session-start future off the TUI's stack in dev builds.
Box::pin(
self.start_agents_overview_session(
tui, app_server, cwd, /*managed_worktree*/ None,
),
)
.await
let result = Box::pin(self.start_agents_overview_session(
tui,
app_server,
cwd,
/*managed_worktree*/ None,
Some(&mut draft),
))
.await;
if self.current_displayed_thread_id() != previous_thread {
draft.flush_pending_paste_newline(tui).await?;
self.chat_widget.restore_startup_draft(draft.take_draft());
} else {
// Retain edits if setup fails so retrying `n` does not lose the draft.
draft.flush_pending_events(tui).await?;
self.agents_overview.new_session_draft = Some(draft);
}
tui.frame_requester().schedule_frame();
result
}
pub(in crate::app) async fn start_agents_overview_session(
@@ -57,6 +93,7 @@ impl App {
codex_worktree::WorktreeManager,
codex_worktree::ManagedWorktree,
)>,
mut startup_draft: Option<&mut StartupDraftPump>,
) -> Result<AppRunControl> {
if self.reconnect.offline || self.windows_sandbox_blocks_thread_switch() {
if let Some((_, checkout)) = &managed_worktree {
@@ -68,7 +105,7 @@ impl App {
return Ok(AppRunControl::Continue);
}
let Some((config, remote_cwd)) = self
.agents_overview_session_config(tui, app_server, cwd)
.agents_overview_session_config(tui, app_server, cwd, startup_draft.as_deref_mut())
.await
else {
if let Some((_, checkout)) = &managed_worktree {
@@ -94,15 +131,22 @@ impl App {
display_label: active.id,
})
});
let result = app_server
.start_thread_with_session_start_source(
&crate::local_settings::LocalSettings::from(&config),
let local_settings = crate::local_settings::LocalSettings::from(&config);
if let Some(draft) = startup_draft.as_deref_mut() {
draft.apply_config(&config);
}
let result = StartupDraftPump::run_with_optional_draft(
startup_draft.as_deref_mut(),
tui,
Box::pin(app_server.start_thread_with_session_start_source(
&local_settings,
&config,
/*session_start_source*/ None,
remote_cwd.as_deref(),
selected_profile.as_ref(),
)
.await;
)),
)
.await;
let started = match result {
Ok(started) => started,
Err(error) => {
@@ -144,9 +188,14 @@ impl App {
.insert(thread_id, started.clone());
// Use the dashboard's existing attachment path, which preserves running agents
// and unsent input in the previous session. Do not send an initial turn.
let control = self
.attach_agents_overview_thread(tui, app_server, thread_id, Some((config, started)))
.await?;
let control = Box::pin(self.attach_agents_overview_thread(
tui,
app_server,
thread_id,
Some((config, started)),
startup_draft,
))
.await?;
if self.current_displayed_thread_id() != Some(thread_id) {
self.agents_overview.blank_sessions.remove(&thread_id);
let _ = app_server.thread_unsubscribe(thread_id).await;
@@ -184,7 +233,7 @@ impl App {
return;
}
let Some((config, _)) = self
.agents_overview_session_config(tui, app_server, cwd)
.agents_overview_session_config(tui, app_server, cwd, /*startup_draft*/ None)
.await
else {
return;

View File

@@ -2472,7 +2472,7 @@ impl App {
};
let manager = pending.manager.clone();
let cwd = AbsolutePathBuf::try_from(checkout.cwd.clone())?;
return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)))).await;
return Box::pin(self.start_agents_overview_session(tui, app_server, Some(cwd), Some((manager, checkout)), /*startup_draft*/ None)).await;
}
Err(error) => self.add_agents_overview_error(error),
}

View File

@@ -4,6 +4,7 @@
use super::*;
use crate::onboarding::onboarding_screen::check_directory_trust;
use crate::startup_draft::StartupDraftPump;
use crate::startup_hooks_review::StartupHooksReviewOutcome;
use crate::startup_hooks_review::load_startup_hooks_review_entry;
use crate::startup_hooks_review::maybe_run_startup_hooks_review;
@@ -135,6 +136,7 @@ impl App {
&mut resume_config.0,
&trust_cwd,
resumed_thread.as_ref(),
/*startup_draft*/ None,
)
.await?;
resume_config.1 = crate::local_settings::LocalSettings::from(&resume_config.0);
@@ -148,6 +150,7 @@ impl App {
config: &mut Config,
cwd: &Path,
resumed_thread: Option<&codex_app_server_protocol::Thread>,
mut startup_draft: Option<&mut StartupDraftPump>,
) -> std::result::Result<(), AppRunControl> {
// Keep the existing explicit remote --cd gate, including retries after cancellation.
// Other remote destinations await authoritative trust-root metadata.
@@ -166,7 +169,7 @@ impl App {
&self.app_server_target,
cwd,
resumed_thread,
/*startup_draft*/ None,
startup_draft.as_deref_mut(),
)
.await
.map_err(|error| {
@@ -187,21 +190,40 @@ impl App {
return Err(AppRunControl::Continue);
}
if result.directory_trust_persisted && !app_server.uses_remote_workspace() {
*config = self
.rebuild_config_for_cwd(config.cwd.to_path_buf())
.await
.map_err(|error| {
self.add_session_picker_error(format!(
"Failed to reload trusted folder settings: {error}"
));
AppRunControl::Continue
})?;
*config = StartupDraftPump::run_with_optional_draft(
startup_draft.as_deref_mut(),
tui,
self.rebuild_config_for_cwd(config.cwd.to_path_buf()),
)
.await
.map_err(|error| {
self.add_session_picker_error(format!(
"Failed to reload trusted folder settings: {error}"
));
AppRunControl::Continue
})?;
if resumed_thread.is_none() {
let hooks = load_startup_hooks_review_entry(
let load_hooks = load_startup_hooks_review_entry(
app_server.request_handle(),
config.cwd.to_path_buf(),
)
.await;
);
let hooks = if let Some(draft) = startup_draft {
draft.apply_config(config);
async {
let hooks = draft.run_until(tui, load_hooks).await?;
draft.flush_pending_events(tui).await?;
Ok::<_, std::io::Error>(hooks)
}
.await
.map_err(|error| {
self.add_session_picker_error(format!(
"Unable to load folder hooks: {error}"
));
AppRunControl::Continue
})?
} else {
load_hooks.await
};
match maybe_run_startup_hooks_review(
app_server,
tui,

View File

@@ -45,6 +45,7 @@ fn trust_launch_folder(app: &mut App) {
#[tokio::test]
async fn command_center_new_reads_server_defaults_for_actual_destination() -> Result<()> {
let mut tui = make_test_tui()?;
tui.pause_events();
for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [
("local", false, false, "launch", "server-model"),
("local", true, false, "destination", "destination-model"),
@@ -263,6 +264,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re
#[tokio::test]
async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> Result<()> {
let mut tui = make_test_tui()?;
tui.pause_events();
for (choice, expected_model, expected_effort) in [
("saved", "server-model", "high"),
("cli_effort", "server-model", "low"),
@@ -362,6 +364,7 @@ async fn command_center_new_preserves_explicit_choices_and_managed_defaults() ->
#[tokio::test]
async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> Result<()> {
let mut tui = make_test_tui()?;
tui.pause_events();
for capability in [
HistoryCapabilities::ConfigReadFails,
HistoryCapabilities::ThreadStartFails,
@@ -459,6 +462,7 @@ async fn command_center_new_preserves_permissions_across_sessions() -> Result<()
.await
);
let mut tui = make_test_tui()?;
tui.pause_events();
for _ in 0..2 {
app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None)
.await?;
@@ -545,6 +549,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result<
RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()),
);
let mut tui = make_test_tui()?;
tui.pause_events();
app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None)
.await?;
assert_eq!(
@@ -647,6 +652,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R
)
.await?;
let mut tui = make_test_tui()?;
tui.pause_events();
app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None)
.await?;
let first = app.chat_widget.thread_id().unwrap();
@@ -855,6 +861,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br
)
.await?;
let mut tui = make_test_tui()?;
tui.pause_events();
let new_session = app.new_agents_overview_session(
&mut tui,
&mut server,
@@ -958,6 +965,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br
&mut failed_server,
Some(unused.cwd.clone().abs()),
Some((manager.clone(), unused.clone())),
/*startup_draft*/ None,
)
.await?;
assert_eq!(app.chat_widget.thread_id(), Some(second));

View File

@@ -61,6 +61,8 @@ pub(crate) enum StartupDraftInitialScreen {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftSessionAction {
New,
/// Keep in-app creation alive while allowing draft edits until setup finishes.
NewFromCommandCenter,
Resume,
Fork,
}
@@ -111,25 +113,11 @@ impl StartupDraft {
initialized_terminal.enhanced_keys_supported,
initialized_terminal.stderr_guard,
);
let (app_event_tx, app_event_rx) = unbounded_channel();
let bottom_pane = startup_draft_bottom_pane(
AppEventSender::new(app_event_tx),
tui.frame_requester(),
tui.enhanced_keys_supported(),
);
let events = tui.event_stream();
let pump = StartupDraftPump::new(&tui, initial_screen, session_action);
let mut draft = Self {
tui,
terminal_restore_guard,
pump: StartupDraftPump {
header: startup_session_header(/*config*/ None),
bottom_pane,
events,
app_event_rx,
initial_screen,
session_action,
pending_paste_newline: None,
},
pump,
};
draft.pump.show_initial_screen(&mut draft.tui)?;
Ok(draft)
@@ -165,6 +153,43 @@ impl StartupDraft {
}
impl StartupDraftPump {
pub(crate) fn new(
tui: &Tui,
initial_screen: StartupDraftInitialScreen,
session_action: StartupDraftSessionAction,
) -> Self {
let (app_event_tx, app_event_rx) = unbounded_channel();
Self {
header: startup_session_header(/*config*/ None),
bottom_pane: startup_draft_bottom_pane(
AppEventSender::new(app_event_tx),
tui.frame_requester(),
tui.enhanced_keys_supported(),
),
events: tui.event_stream(),
app_event_rx,
initial_screen,
session_action,
pending_paste_newline: None,
}
}
/// Keep a provisional composer responsive when the caller has one to display.
pub(crate) async fn run_with_optional_draft<F, T, E>(
draft: Option<&mut Self>,
tui: &mut Tui,
future: F,
) -> Result<T, E>
where
F: Future<Output = Result<T, E>>,
E: From<io::Error>,
{
match draft {
Some(draft) => draft.run_until(tui, future).await?,
None => future.await,
}
}
/// Refresh the session header and safe editor shortcuts without enabling modal editing.
pub(crate) fn apply_config(&mut self, config: &Config) {
let local_settings = crate::local_settings::LocalSettings::from(config);
@@ -264,6 +289,10 @@ impl StartupDraftPump {
/// Preserve the editable draft, its cursor, and any pending large-paste placeholders.
pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot {
self.take_draft()
}
pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot {
self.bottom_pane.flush_composer_paste_burst();
self.bottom_pane.composer_draft_snapshot()
}
@@ -346,16 +375,22 @@ impl StartupDraftPump {
self.pending_paste_newline = Some((Instant::now(), "\n".to_string()));
}
}
handle_startup_draft_key(&mut self.bottom_pane, key).inspect_err(|error| {
if StartupCancelled::matches(error)
&& let Err(clear_error) = tui.terminal.clear()
{
tracing::warn!(
error = %clear_error,
"failed to clear the cancelled startup composer"
);
match handle_startup_draft_key(&mut self.bottom_pane, key) {
Err(error) if StartupCancelled::matches(&error) => {
// In-app setup owns a live creation request. Keep pumping input
// instead of abandoning it or blocking on cancellation cleanup.
if self.session_action != StartupDraftSessionAction::NewFromCommandCenter {
if let Err(clear_error) = tui.terminal.clear() {
tracing::warn!(
error = %clear_error,
"failed to clear the cancelled startup composer"
);
}
return Err(error);
}
}
})?;
result => result?,
}
}
TuiEvent::Paste(text) => {
if self.initial_screen == StartupDraftInitialScreen::Composer {
@@ -477,7 +512,7 @@ fn startup_draft_renderable<'a>(
let mut renderable = FlexRenderable::new();
renderable.push(/*flex*/ 1, RenderableItem::Borrowed(header));
let loading_message = match session_action {
StartupDraftSessionAction::New => None,
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => None,
StartupDraftSessionAction::Resume => Some(" Resuming session…"),
StartupDraftSessionAction::Fork => Some(" Forking session…"),
};