From d9531309e3e12bfa114fa4db332e5728ff2f15e0 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Thu, 18 Jun 2026 16:39:46 -0700 Subject: [PATCH 1/6] Add workspace headline statusline item --- codex-rs/tui/src/app/background_requests.rs | 34 ++++++ codex-rs/tui/src/app/event_dispatch.rs | 7 ++ codex-rs/tui/src/app_event.rs | 7 ++ .../tui/src/bottom_pane/status_line_setup.rs | 7 ++ .../tui/src/bottom_pane/status_line_style.rs | 2 +- .../src/bottom_pane/status_surface_preview.rs | 3 + codex-rs/tui/src/chatwidget.rs | 9 ++ codex-rs/tui/src/chatwidget/constructor.rs | 4 + .../tui/src/chatwidget/status_surfaces.rs | 86 ++++++++++++++ .../src/chatwidget/tests/status_and_layout.rs | 69 ++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/workspace_messages.rs | 105 ++++++++++++++++++ 12 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/workspace_messages.rs diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index bb05e1a3f4e6..12cf8344c105 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -31,6 +31,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); +const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(/*millis*/ 1000); impl App { pub(super) fn fetch_mcp_inventory( @@ -158,6 +160,25 @@ impl App { }); } + pub(super) fn refresh_status_line_workspace_headline(&mut self, app_server: &AppServerSession) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + WORKSPACE_HEADLINE_FETCH_TIMEOUT, + fetch_workspace_messages(request_handle), + ) + .await + .map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string()) + .and_then(|result| { + result + .map(crate::workspace_messages::workspace_headline_from_response) + .map_err(|err| err.to_string()) + }); + app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { result }); + }); + } + pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, @@ -796,6 +817,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request( .wrap_err("account/rateLimitResetCredit/consume failed in TUI") } +pub(super) async fn fetch_workspace_messages( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::GetWorkspaceMessages { + request_id, + params: None, + }) + .await + .wrap_err("account/workspaceMessages/read failed in TUI") +} + pub(super) async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 16e4aaa62747..4531a2ca9aea 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -716,6 +716,9 @@ impl App { AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } + AppEvent::RefreshStatusLineWorkspaceHeadline => { + self.refresh_status_line_workspace_headline(app_server); + } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } @@ -2010,6 +2013,10 @@ impl App { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } + AppEvent::StatusLineWorkspaceHeadlineUpdated { result } => { + self.chat_widget.set_status_line_workspace_headline(result); + tui.frame_requester().schedule_frame(); + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1143848d1377..5d223350b71d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -341,6 +341,9 @@ pub(crate) enum AppEvent { result: Result, }, + /// Fetch workspace messages for the status-line headline item. + RefreshStatusLineWorkspaceHeadline, + /// Commit settled asynchronous usage output after active-output barriers clear. CommitPendingUsageOutput, @@ -977,6 +980,10 @@ pub(crate) enum AppEvent { cwd: PathBuf, summary: crate::chatwidget::StatusLineGitSummary, }, + /// Async update of the workspace notification headline for status line rendering. + StatusLineWorkspaceHeadlineUpdated { + result: Result, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4dd62a7d20f5..63ce4237dbe4 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem { /// Current thread title (if set by user). ThreadTitle, + /// Current workspace notification headline. + WorkspaceHeadline, + /// Latest checklist task progress from `update_plan` (if available). TaskProgress, } @@ -185,6 +188,9 @@ impl StatusLineItem { StatusLineItem::ThreadTitle => { "Current thread title, or thread identifier when unnamed" } + StatusLineItem::WorkspaceHeadline => { + "Workspace notification headline (Enterprise workspaces only; omitted when unavailable)" + } StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" } @@ -217,6 +223,7 @@ impl StatusLineItem { StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, + StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 170c4641d2b2..4198943a4f79 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -49,7 +49,7 @@ impl StatusLineAccent { StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, - StatusLineItem::ThreadTitle => Self::Thread, + StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index bd0a94a4d40c..b8b7a6fbd4a9 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem { SessionId, FastMode, RawOutput, + WorkspaceHeadline, Model, ModelWithReasoning, Reasoning, @@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::RawOutput => "raw output", + StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::Reasoning => "medium", @@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem { Self::SessionId, Self::FastMode, Self::RawOutput, + Self::WorkspaceHeadline, Self::Model, Self::ModelWithReasoning, Self::Reasoning, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd3c140e3fba..e2252d901f54 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -727,6 +727,14 @@ pub(crate) struct ChatWidget { status_line_git_summary_pending: bool, // True once we've attempted a Git summary lookup for the current CWD. status_line_git_summary_lookup_complete: bool, + // Cached workspace notification headline for the status line. + status_line_workspace_headline: Option, + // True while an async workspace headline fetch is in flight. + status_line_workspace_headline_pending: bool, + // Last time a workspace headline fetch was requested. + status_line_workspace_headline_last_requested_at: Option, + // Set after the backend reports the workspace-message feature gate is disabled. + status_line_workspace_messages_disabled: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1188,6 +1196,7 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 77fdd74e5240..68f862fd2672 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -229,6 +229,10 @@ impl ChatWidget { status_line_git_summary_cwd: None, status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, + status_line_workspace_headline: None, + status_line_workspace_headline_pending: false, + status_line_workspace_headline_last_requested_at: None, + status_line_workspace_messages_disabled: false, current_goal_status_indicator: None, current_goal_status: None, external_editor_state: ExternalEditorState::Closed, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 592dd0438172..c305b70c1557 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -65,6 +65,11 @@ impl StatusSurfaceSelections { .status_line_items .contains(&StatusLineItem::BranchChanges) } + + fn uses_workspace_headline(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::WorkspaceHeadline) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -157,6 +162,15 @@ impl ChatWidget { self.request_status_line_git_summary(cwd); } } + + if !selections.uses_workspace_headline() { + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending = false; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; + } else { + self.request_status_line_workspace_headline_if_due(Instant::now()); + } } fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { @@ -553,6 +567,76 @@ impl ChatWidget { }); } + fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) { + if !self.status_line_workspace_headline_should_fetch(now) { + return; + } + self.status_line_workspace_headline_pending = true; + self.status_line_workspace_headline_last_requested_at = Some(now); + self.app_event_tx + .send(AppEvent::RefreshStatusLineWorkspaceHeadline); + } + + fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { + if self.status_line_workspace_headline_pending + || self.status_line_workspace_messages_disabled + || !self.has_chatgpt_account + || !self.has_codex_backend_auth + || !crate::workspace_messages::plan_type_allows_workspace_headline(self.plan_type) + { + return false; + } + + self.status_line_workspace_headline_last_requested_at + .is_none_or(|last_requested_at| { + now.saturating_duration_since(last_requested_at) + >= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL + }) + } + + pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) { + let now = Instant::now(); + if self.status_line_workspace_headline_should_fetch(now) + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.refresh_status_line(); + } + } + + pub(crate) fn set_status_line_workspace_headline( + &mut self, + result: Result, + ) { + self.status_line_workspace_headline_pending = false; + match result { + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { + self.status_line_workspace_messages_disabled = false; + self.status_line_workspace_headline = headline; + } + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => { + self.status_line_workspace_messages_disabled = true; + self.status_line_workspace_headline = None; + } + Err(err) => { + tracing::debug!(error = %err, "failed to fetch workspace headline"); + } + } + + if !self.status_line_workspace_messages_disabled + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.frame_requester + .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); + } + self.refresh_status_line(); + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -653,6 +737,7 @@ impl ChatWidget { } }, ), + StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } @@ -693,6 +778,7 @@ impl ChatWidget { StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, + StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 583377bace10..4ddd56cbff73 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2141,6 +2141,75 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() { ); } +#[tokio::test] +async fn status_line_workspace_headline_renders_cached_value() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + + chat.refresh_status_line(); + + assert_eq!( + status_line_text(&chat), + Some("Workspace maintenance starts at 5pm".to_string()) + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_workspace_headline_omits_when_unavailable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec![ + "workspace-headline".to_string(), + "run-state".to_string(), + ]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Ready".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be omitted without warning when no headline is cached" + ); +} + +#[tokio::test] +async fn workspace_headline_update_applies_feature_disabled_result() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old headline".to_string()); + + chat.set_status_line_workspace_headline(Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled, + )); + + assert_eq!(status_line_text(&chat), None); + assert!(chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn workspace_headline_update_applies_available_headline() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.set_status_line_workspace_headline(Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Fresh workspace headline".to_string(), + )), + )); + + assert_eq!( + status_line_text(&chat), + Some("Fresh workspace headline".to_string()) + ); + assert!(!chat.status_line_workspace_messages_disabled); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f189b231497f..84c59b97cf35 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -200,6 +200,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 000000000000..0b8e6524b0b0 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -0,0 +1,105 @@ +use codex_app_server_protocol::GetWorkspaceMessagesResponse; +use codex_app_server_protocol::WorkspaceMessageType; +use codex_protocol::account::PlanType; +use std::time::Duration; + +pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(10); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WorkspaceHeadlineFetchResult { + Available(Option), + FeatureDisabled, +} + +pub(crate) fn workspace_headline_from_response( + response: GetWorkspaceMessagesResponse, +) -> WorkspaceHeadlineFetchResult { + if !response.feature_enabled { + return WorkspaceHeadlineFetchResult::FeatureDisabled; + } + + WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| { + (message.message_type == WorkspaceMessageType::Headline) + .then(|| message.message_body.trim().to_string()) + .filter(|headline| !headline.is_empty()) + })) +} + +pub(crate) fn plan_type_allows_workspace_headline(plan_type: Option) -> bool { + matches!( + plan_type, + Some(PlanType::Enterprise | PlanType::EnterpriseCbpUsageBased) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::WorkspaceMessage; + use pretty_assertions::assert_eq; + + #[test] + fn workspace_headline_from_response_uses_first_non_empty_headline() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![ + WorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: WorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "empty-headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " ".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " Workspace headline ".to_string(), + created_at: None, + archived_at: None, + }, + ], + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) + ); + } + + #[test] + fn workspace_headline_from_response_reports_feature_disabled() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: false, + messages: Vec::new(), + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::FeatureDisabled + ); + } + + #[test] + fn workspace_headline_plan_gate_allows_enterprise_plans_only() { + let cases = [ + (Some(PlanType::Enterprise), true), + (Some(PlanType::EnterpriseCbpUsageBased), true), + (Some(PlanType::Business), false), + (Some(PlanType::Team), false), + (Some(PlanType::Edu), false), + (Some(PlanType::Pro), false), + (None, false), + ]; + + for (plan_type, expected) in cases { + assert_eq!(plan_type_allows_workspace_headline(plan_type), expected); + } + } +} From ffe6e587283edc7936401b076a3b78911a57d16a Mon Sep 17 00:00:00 2001 From: xli-oai Date: Sat, 20 Jun 2026 01:06:52 -0700 Subject: [PATCH 2/6] Remove duplicate workspace headline plan gate --- .../tui/src/chatwidget/status_surfaces.rs | 1 - codex-rs/tui/src/workspace_messages.rs | 25 ------------------- 2 files changed, 26 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index c305b70c1557..f99f28a386e1 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -582,7 +582,6 @@ impl ChatWidget { || self.status_line_workspace_messages_disabled || !self.has_chatgpt_account || !self.has_codex_backend_auth - || !crate::workspace_messages::plan_type_allows_workspace_headline(self.plan_type) { return false; } diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs index 0b8e6524b0b0..b8dcd9506b04 100644 --- a/codex-rs/tui/src/workspace_messages.rs +++ b/codex-rs/tui/src/workspace_messages.rs @@ -1,6 +1,5 @@ use codex_app_server_protocol::GetWorkspaceMessagesResponse; use codex_app_server_protocol::WorkspaceMessageType; -use codex_protocol::account::PlanType; use std::time::Duration; pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(10); @@ -25,13 +24,6 @@ pub(crate) fn workspace_headline_from_response( })) } -pub(crate) fn plan_type_allows_workspace_headline(plan_type: Option) -> bool { - matches!( - plan_type, - Some(PlanType::Enterprise | PlanType::EnterpriseCbpUsageBased) - ) -} - #[cfg(test)] mod tests { use super::*; @@ -85,21 +77,4 @@ mod tests { WorkspaceHeadlineFetchResult::FeatureDisabled ); } - - #[test] - fn workspace_headline_plan_gate_allows_enterprise_plans_only() { - let cases = [ - (Some(PlanType::Enterprise), true), - (Some(PlanType::EnterpriseCbpUsageBased), true), - (Some(PlanType::Business), false), - (Some(PlanType::Team), false), - (Some(PlanType::Edu), false), - (Some(PlanType::Pro), false), - (None, false), - ]; - - for (plan_type, expected) in cases { - assert_eq!(plan_type_allows_workspace_headline(plan_type), expected); - } - } } From 97a133293de7b4db43ae48b5562513896f200b9b Mon Sep 17 00:00:00 2001 From: xli-oai Date: Sun, 21 Jun 2026 19:00:21 -0700 Subject: [PATCH 3/6] Poll workspace headline every minute --- codex-rs/tui/src/workspace_messages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs index b8dcd9506b04..b6193649b7dc 100644 --- a/codex-rs/tui/src/workspace_messages.rs +++ b/codex-rs/tui/src/workspace_messages.rs @@ -2,7 +2,7 @@ use codex_app_server_protocol::GetWorkspaceMessagesResponse; use codex_app_server_protocol::WorkspaceMessageType; use std::time::Duration; -pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(10); +pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(60); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum WorkspaceHeadlineFetchResult { From 88a6fdb44aba1799ea9843131decadc73055d7d2 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Sun, 21 Jun 2026 19:05:20 -0700 Subject: [PATCH 4/6] Allow two seconds for workspace headline fetch --- codex-rs/tui/src/app/background_requests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 12cf8344c105..a3f78875800d 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -32,7 +32,7 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration = - std::time::Duration::from_millis(/*millis*/ 1000); + std::time::Duration::from_millis(/*millis*/ 2000); impl App { pub(super) fn fetch_mcp_inventory( From 6c7a1d9971fb61b26bb54246f28eec16222c67c4 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Sun, 21 Jun 2026 20:25:33 -0700 Subject: [PATCH 5/6] Address workspace headline review feedback --- codex-rs/tui/src/chatwidget/settings.rs | 5 ++ ...s_line_setup_popup_workspace_headline.snap | 20 +++++++ .../tui/src/chatwidget/status_surfaces.rs | 1 - .../src/chatwidget/tests/status_and_layout.rs | 41 ++++++++++++++ .../tests/status_surface_previews.rs | 12 ++++ codex-rs/tui/src/workspace_messages.rs | 55 +------------------ codex-rs/tui/src/workspace_messages_tests.rs | 51 +++++++++++++++++ 7 files changed, 131 insertions(+), 54 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap create mode 100644 codex-rs/tui/src/workspace_messages_tests.rs diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f8d1ad4b5b..76f22a8759be 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -224,6 +224,10 @@ impl ChatWidget { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); } + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending = false; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; @@ -232,6 +236,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); + self.refresh_status_line(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap new file mode 100644 index 000000000000..32ad18ec6537 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted … + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + Workspace maintenance starts at 5pm + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index f99f28a386e1..6d900e9072f5 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -580,7 +580,6 @@ impl ChatWidget { fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { if self.status_line_workspace_headline_pending || self.status_line_workspace_messages_disabled - || !self.has_chatgpt_account || !self.has_codex_backend_auth { return false; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4ddd56cbff73..fce86494ac0e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2210,6 +2210,47 @@ async fn workspace_headline_update_applies_available_headline() { assert!(!chat.status_line_workspace_messages_disabled); } +#[tokio::test] +async fn account_update_clears_workspace_headline_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); + chat.status_line_workspace_headline_pending = true; + chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); + chat.status_line_workspace_messages_disabled = true; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending, + chat.status_line_workspace_headline_last_requested_at, + chat.status_line_workspace_messages_disabled, + ), + (None, false, None, false) + ); +} + +#[tokio::test] +async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshStatusLineWorkspaceHeadline) + ); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d24fd..63005cb95759 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { ); } +#[tokio::test] +async fn status_line_setup_popup_workspace_headline_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_workspace_headline", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_surface_preview_lines_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs index b6193649b7dc..b31f6d437367 100644 --- a/codex-rs/tui/src/workspace_messages.rs +++ b/codex-rs/tui/src/workspace_messages.rs @@ -25,56 +25,5 @@ pub(crate) fn workspace_headline_from_response( } #[cfg(test)] -mod tests { - use super::*; - use codex_app_server_protocol::WorkspaceMessage; - use pretty_assertions::assert_eq; - - #[test] - fn workspace_headline_from_response_uses_first_non_empty_headline() { - let response = GetWorkspaceMessagesResponse { - feature_enabled: true, - messages: vec![ - WorkspaceMessage { - message_id: "announcement-id".to_string(), - message_type: WorkspaceMessageType::Announcement, - message_body: "Announcement body".to_string(), - created_at: None, - archived_at: None, - }, - WorkspaceMessage { - message_id: "empty-headline-id".to_string(), - message_type: WorkspaceMessageType::Headline, - message_body: " ".to_string(), - created_at: None, - archived_at: None, - }, - WorkspaceMessage { - message_id: "headline-id".to_string(), - message_type: WorkspaceMessageType::Headline, - message_body: " Workspace headline ".to_string(), - created_at: None, - archived_at: None, - }, - ], - }; - - assert_eq!( - workspace_headline_from_response(response), - WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) - ); - } - - #[test] - fn workspace_headline_from_response_reports_feature_disabled() { - let response = GetWorkspaceMessagesResponse { - feature_enabled: false, - messages: Vec::new(), - }; - - assert_eq!( - workspace_headline_from_response(response), - WorkspaceHeadlineFetchResult::FeatureDisabled - ); - } -} +#[path = "workspace_messages_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs new file mode 100644 index 000000000000..962d2ceba103 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use codex_app_server_protocol::WorkspaceMessage; +use pretty_assertions::assert_eq; + +#[test] +fn workspace_headline_from_response_uses_first_non_empty_headline() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![ + WorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: WorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "empty-headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " ".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " Workspace headline ".to_string(), + created_at: None, + archived_at: None, + }, + ], + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) + ); +} + +#[test] +fn workspace_headline_from_response_reports_feature_disabled() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: false, + messages: Vec::new(), + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::FeatureDisabled + ); +} From 2401792aa622e49e9121a0146a7c08bfbdc28306 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Sun, 21 Jun 2026 21:05:30 -0700 Subject: [PATCH 6/6] Ignore stale workspace headline responses --- codex-rs/tui/src/app/background_requests.rs | 8 +- codex-rs/tui/src/app/event_dispatch.rs | 14 ++- codex-rs/tui/src/app_event.rs | 5 +- codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/chatwidget/constructor.rs | 3 +- codex-rs/tui/src/chatwidget/settings.rs | 2 +- .../tui/src/chatwidget/status_surfaces.rs | 23 +++- .../src/chatwidget/tests/status_and_layout.rs | 111 ++++++++++++++++-- 8 files changed, 141 insertions(+), 31 deletions(-) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index a3f78875800d..08e7f9b07118 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -160,7 +160,11 @@ impl App { }); } - pub(super) fn refresh_status_line_workspace_headline(&mut self, app_server: &AppServerSession) { + pub(super) fn refresh_status_line_workspace_headline( + &mut self, + app_server: &AppServerSession, + request_id: u64, + ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { @@ -175,7 +179,7 @@ impl App { .map(crate::workspace_messages::workspace_headline_from_response) .map_err(|err| err.to_string()) }); - app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { result }); + app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result }); }); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 4531a2ca9aea..c4c380c67ae1 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -716,8 +716,8 @@ impl App { AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } - AppEvent::RefreshStatusLineWorkspaceHeadline => { - self.refresh_status_line_workspace_headline(app_server); + AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => { + self.refresh_status_line_workspace_headline(app_server, request_id); } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; @@ -2013,9 +2013,13 @@ impl App { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } - AppEvent::StatusLineWorkspaceHeadlineUpdated { result } => { - self.chat_widget.set_status_line_workspace_headline(result); - tui.frame_requester().schedule_frame(); + AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => { + if self + .chat_widget + .set_status_line_workspace_headline(request_id, result) + { + tui.frame_requester().schedule_frame(); + } } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 5d223350b71d..61642d5791e1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -342,7 +342,9 @@ pub(crate) enum AppEvent { }, /// Fetch workspace messages for the status-line headline item. - RefreshStatusLineWorkspaceHeadline, + RefreshStatusLineWorkspaceHeadline { + request_id: u64, + }, /// Commit settled asynchronous usage output after active-output barriers clear. CommitPendingUsageOutput, @@ -982,6 +984,7 @@ pub(crate) enum AppEvent { }, /// Async update of the workspace notification headline for status line rendering. StatusLineWorkspaceHeadlineUpdated { + request_id: u64, result: Result, }, /// Apply a user-confirmed status-line item ordering/selection. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e2252d901f54..20bef58a1a29 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -729,8 +729,10 @@ pub(crate) struct ChatWidget { status_line_git_summary_lookup_complete: bool, // Cached workspace notification headline for the status line. status_line_workspace_headline: Option, - // True while an async workspace headline fetch is in flight. - status_line_workspace_headline_pending: bool, + // Request ID for the async workspace headline fetch currently in flight. + status_line_workspace_headline_pending_request_id: Option, + // Request ID to assign to the next workspace headline fetch. + next_status_line_workspace_headline_request_id: u64, // Last time a workspace headline fetch was requested. status_line_workspace_headline_last_requested_at: Option, // Set after the backend reports the workspace-message feature gate is disabled. diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 68f862fd2672..17f2c8c3b0f4 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -230,7 +230,8 @@ impl ChatWidget { status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, status_line_workspace_headline: None, - status_line_workspace_headline_pending: false, + status_line_workspace_headline_pending_request_id: None, + next_status_line_workspace_headline_request_id: 0, status_line_workspace_headline_last_requested_at: None, status_line_workspace_messages_disabled: false, current_goal_status_indicator: None, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 76f22a8759be..75c4cc66e431 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -225,7 +225,7 @@ impl ChatWidget { self.clear_pending_rate_limit_reset_requests(); } self.status_line_workspace_headline = None; - self.status_line_workspace_headline_pending = false; + self.status_line_workspace_headline_pending_request_id = None; self.status_line_workspace_headline_last_requested_at = None; self.status_line_workspace_messages_disabled = false; self.status_account_display = status_account_display; diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 6d900e9072f5..5f02dd8d690a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -165,7 +165,7 @@ impl ChatWidget { if !selections.uses_workspace_headline() { self.status_line_workspace_headline = None; - self.status_line_workspace_headline_pending = false; + self.status_line_workspace_headline_pending_request_id = None; self.status_line_workspace_headline_last_requested_at = None; self.status_line_workspace_messages_disabled = false; } else { @@ -571,14 +571,20 @@ impl ChatWidget { if !self.status_line_workspace_headline_should_fetch(now) { return; } - self.status_line_workspace_headline_pending = true; + let request_id = self.next_status_line_workspace_headline_request_id; + self.next_status_line_workspace_headline_request_id = self + .next_status_line_workspace_headline_request_id + .wrapping_add(/*rhs*/ 1); + self.status_line_workspace_headline_pending_request_id = Some(request_id); self.status_line_workspace_headline_last_requested_at = Some(now); self.app_event_tx - .send(AppEvent::RefreshStatusLineWorkspaceHeadline); + .send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }); } fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { - if self.status_line_workspace_headline_pending + if self + .status_line_workspace_headline_pending_request_id + .is_some() || self.status_line_workspace_messages_disabled || !self.has_codex_backend_auth { @@ -606,9 +612,13 @@ impl ChatWidget { pub(crate) fn set_status_line_workspace_headline( &mut self, + request_id: u64, result: Result, - ) { - self.status_line_workspace_headline_pending = false; + ) -> bool { + if self.status_line_workspace_headline_pending_request_id != Some(request_id) { + return false; + } + self.status_line_workspace_headline_pending_request_id = None; match result { Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { self.status_line_workspace_messages_disabled = false; @@ -633,6 +643,7 @@ impl ChatWidget { .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); } self.refresh_status_line(); + true } /// Resolves a display string for one configured status-line item. diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index fce86494ac0e..9e528fb6c207 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) { chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); } +fn take_workspace_headline_request_id( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + match rx.try_recv() { + Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id, + event => panic!("expected workspace headline refresh, got {event:?}"), + } +} + /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { @@ -2183,9 +2192,12 @@ async fn workspace_headline_update_applies_feature_disabled_result() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Old headline".to_string()); + let request_id = 3; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); - chat.set_status_line_workspace_headline(Ok( - crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled, + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), )); assert_eq!(status_line_text(&chat), None); @@ -2196,11 +2208,16 @@ async fn workspace_headline_update_applies_feature_disabled_result() { async fn workspace_headline_update_applies_available_headline() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); - - chat.set_status_line_workspace_headline(Ok( - crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( - "Fresh workspace headline".to_string(), - )), + let request_id = 4; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Fresh workspace headline".to_string(), + )) + ), )); assert_eq!( @@ -2215,7 +2232,7 @@ async fn account_update_clears_workspace_headline_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); - chat.status_line_workspace_headline_pending = true; + chat.status_line_workspace_headline_pending_request_id = Some(5); chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); chat.status_line_workspace_messages_disabled = true; @@ -2227,11 +2244,11 @@ async fn account_update_clears_workspace_headline_state() { assert_eq!( ( status_line_text(&chat), - chat.status_line_workspace_headline_pending, + chat.status_line_workspace_headline_pending_request_id, chat.status_line_workspace_headline_last_requested_at, chat.status_line_workspace_messages_disabled, ), - (None, false, None, false) + (None, None, None, false) ); } @@ -2245,9 +2262,77 @@ async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, ); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::RefreshStatusLineWorkspaceHeadline) + let request_id = take_workspace_headline_request_id(&mut rx); + assert_eq!( + chat.status_line_workspace_headline_pending_request_id, + Some(request_id) + ); +} + +#[tokio::test] +async fn account_update_discards_stale_workspace_headline_results() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("first@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let stale_request_id = take_workspace_headline_request_id(&mut rx); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("second@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let current_request_id = take_workspace_headline_request_id(&mut rx); + + assert_ne!(stale_request_id, current_request_id); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "First account headline".to_string(), + )) + ), + )); + assert_eq!( + ( + chat.status_line_workspace_headline.clone(), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (None, Some(current_request_id), false) + ); + + assert!(chat.set_status_line_workspace_headline( + current_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Second account headline".to_string(), + )) + ), + )); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (Some("Second account headline".to_string()), None, false,) ); }