From 36f2fe8af94bf678f6653e02bd5e6cd7a558aa21 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 29 Jan 2026 18:12:46 -0800 Subject: [PATCH] feat(tui): route employee feedback follow-ups to internal link (#10198) ## Problem OpenAI employees were sent to the public GitHub issue flow after `/feedback`, which is the wrong follow-up path internally. ## Mental model After feedback upload completes, we render a follow-up link/message. That link should be audience-aware but must not change the upload pipeline itself. ## Non-goals - Changing how feedback is captured or uploaded - Changing external user behavior ## Tradeoffs We detect employees via the authenticated account email suffix (`@openai.com`). If the email is unavailable (e.g., API key auth), we default to the external behavior. ## Architecture - Introduce `FeedbackAudience` and thread it from `App` -> `ChatWidget` -> `FeedbackNoteView` - Gate internal messaging/links on `FeedbackAudience::OpenAiEmployee` - Internal follow-up link is now `http://go/codex-feedback-internal` - External GitHub URL remains byte-for-byte identical ## Observability No new telemetry; this only changes rendered follow-up instructions. ## Tests - `just fmt` - `cargo test -p codex-tui --lib` --- codex-rs/tui/src/app.rs | 21 +++ codex-rs/tui/src/bottom_pane/feedback_view.rs | 150 ++++++++++++++---- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 10 ++ codex-rs/tui/src/chatwidget/tests.rs | 5 + 5 files changed, 154 insertions(+), 33 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ae1dc4b322..fe33df8378 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,6 +7,7 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; @@ -541,6 +542,7 @@ pub(crate) struct App { /// transcript cells. pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, + feedback_audience: FeedbackAudience, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -599,6 +601,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, + feedback_audience: self.feedback_audience, model: Some(self.chat_widget.current_model().to_string()), otel_manager: self.otel_manager.clone(), } @@ -957,6 +960,17 @@ impl App { let auth = auth_manager.auth().await; let auth_ref = auth.as_ref(); + // Determine who should see internal Slack routing. We treat + // `@openai.com` emails as employees and default to `External` when the + // email is unavailable (for example, API key auth). + let feedback_audience = if auth_ref + .and_then(CodexAuth::get_account_email) + .is_some_and(|email| email.ends_with("@openai.com")) + { + FeedbackAudience::OpenAiEmployee + } else { + FeedbackAudience::External + }; let otel_manager = OtelManager::new( ThreadId::new(), model.as_str(), @@ -987,6 +1001,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, + feedback_audience, model: Some(model.clone()), otel_manager: otel_manager.clone(), }; @@ -1015,6 +1030,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, + feedback_audience, model: config.model.clone(), otel_manager: otel_manager.clone(), }; @@ -1043,6 +1059,7 @@ impl App { models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, + feedback_audience, model: config.model.clone(), otel_manager: otel_manager.clone(), }; @@ -1078,6 +1095,7 @@ impl App { backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), + feedback_audience, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), @@ -1268,6 +1286,7 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, + feedback_audience: self.feedback_audience, model: Some(model), otel_manager: self.otel_manager.clone(), }; @@ -2601,6 +2620,7 @@ mod tests { backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), @@ -2653,6 +2673,7 @@ mod tests { backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 8fef8e79a1..a76d73475e 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -29,6 +29,18 @@ use super::textarea::TextAreaState; const BASE_BUG_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; +/// Internal routing link for employee feedback follow-ups. This must not be shown to external users. +const CODEX_FEEDBACK_INTERNAL_URL: &str = "http://go/codex-feedback-internal"; + +/// The target audience for feedback follow-up instructions. +/// +/// This is used strictly for messaging/links after feedback upload completes. It +/// must not change feedback upload behavior itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FeedbackAudience { + OpenAiEmployee, + External, +} /// Minimal input overlay to collect an optional feedback note, then upload /// both logs and rollout with classification + metadata. @@ -38,6 +50,7 @@ pub(crate) struct FeedbackNoteView { rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, + feedback_audience: FeedbackAudience, // UI state textarea: TextArea, @@ -52,6 +65,7 @@ impl FeedbackNoteView { rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, + feedback_audience: FeedbackAudience, ) -> Self { Self { category, @@ -59,6 +73,7 @@ impl FeedbackNoteView { rollout_path, app_event_tx, include_logs, + feedback_audience, textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), complete: false, @@ -96,30 +111,49 @@ impl FeedbackNoteView { } else { "• Feedback recorded (no logs)." }; - let issue_url = issue_url_for_category(self.category, &thread_id); + let issue_url = + issue_url_for_category(self.category, &thread_id, self.feedback_audience); let mut lines = vec![Line::from(match issue_url.as_ref() { + Some(_) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => { + format!("{prefix} Please report this in #codex-feedback:") + } Some(_) => format!("{prefix} Please open an issue using the following URL:"), None => format!("{prefix} Thanks for the feedback!"), })]; - if let Some(url) = issue_url { - lines.extend([ - "".into(), - Line::from(vec![" ".into(), url.cyan().underlined()]), - "".into(), - Line::from(vec![ - " Or mention your thread ID ".into(), - std::mem::take(&mut thread_id).bold(), - " in an existing issue.".into(), - ]), - ]); - } else { - lines.extend([ - "".into(), - Line::from(vec![ - " Thread ID: ".into(), - std::mem::take(&mut thread_id).bold(), - ]), - ]); + match issue_url { + Some(url) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(" Share this and add some info about your problem:"), + Line::from(vec![ + " ".into(), + format!("go/codex-feedback/{thread_id}").bold(), + ]), + ]); + } + Some(url) => { + lines.extend([ + "".into(), + Line::from(vec![" ".into(), url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + std::mem::take(&mut thread_id).bold(), + " in an existing issue.".into(), + ]), + ]); + } + None => { + lines.extend([ + "".into(), + Line::from(vec![ + " Thread ID: ".into(), + std::mem::take(&mut thread_id).bold(), + ]), + ]); + } } self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::PlainHistoryCell::new(lines), @@ -335,15 +369,35 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str { } } -fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { +fn issue_url_for_category( + category: FeedbackCategory, + thread_id: &str, + feedback_audience: FeedbackAudience, +) -> Option { + // Only certain categories provide a follow-up link. We intentionally keep + // the external GitHub behavior identical while routing internal users to + // the internal go link. match category { - FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( - format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"), - ), + FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => { + Some(match feedback_audience { + FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id), + FeedbackAudience::External => { + format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}") + } + }) + } FeedbackCategory::GoodResult => None, } } +/// Build the internal follow-up URL. +/// +/// We accept a `thread_id` so the call site stays symmetric with the external +/// path, but we currently point to a fixed channel without prefilling text. +fn slack_feedback_url(_thread_id: &str) -> String { + CODEX_FEEDBACK_INTERNAL_URL.to_string() +} + // Build the selection popup params for feedback categories. pub(crate) fn feedback_selection_params( app_event_tx: AppEventSender, @@ -523,7 +577,14 @@ mod tests { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); - FeedbackNoteView::new(category, snapshot, None, tx, true) + FeedbackNoteView::new( + category, + snapshot, + None, + tx, + true, + FeedbackAudience::External, + ) } #[test] @@ -556,19 +617,42 @@ mod tests { #[test] fn issue_url_available_for_bug_bad_result_and_other() { - let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); - assert!( - bug_url - .as_deref() - .is_some_and(|url| url.contains("template=2-bug-report")) + let bug_url = issue_url_for_category( + FeedbackCategory::Bug, + "thread-1", + FeedbackAudience::OpenAiEmployee, ); + let expected_slack_url = "http://go/codex-feedback-internal".to_string(); + assert_eq!(bug_url.as_deref(), Some(expected_slack_url.as_str())); - let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2"); + let bad_result_url = issue_url_for_category( + FeedbackCategory::BadResult, + "thread-2", + FeedbackAudience::OpenAiEmployee, + ); assert!(bad_result_url.is_some()); - let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3"); + let other_url = issue_url_for_category( + FeedbackCategory::Other, + "thread-3", + FeedbackAudience::OpenAiEmployee, + ); assert!(other_url.is_some()); - assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none()); + assert!( + issue_url_for_category( + FeedbackCategory::GoodResult, + "t", + FeedbackAudience::OpenAiEmployee + ) + .is_none() + ); + let bug_url_non_employee = + issue_url_for_category(FeedbackCategory::Bug, "t", FeedbackAudience::External); + let expected_external_url = format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20t"); + assert_eq!( + bug_url_non_employee.as_deref(), + Some(expected_external_url.as_str()) + ); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d08ccf5068..a632fd4468 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -67,6 +67,7 @@ mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; +pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 74200e470a..2833d4863e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -147,6 +147,7 @@ use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; +use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; @@ -379,6 +380,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, + pub(crate) feedback_audience: FeedbackAudience, pub(crate) model: Option, pub(crate) otel_manager: OtelManager, } @@ -558,6 +560,7 @@ pub(crate) struct ChatWidget { last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, + feedback_audience: FeedbackAudience, // Current session rollout path (if known) current_rollout_path: Option, external_editor_state: ExternalEditorState, @@ -845,6 +848,7 @@ impl ChatWidget { rollout, self.app_event_tx.clone(), include_logs, + self.feedback_audience, ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); @@ -2047,6 +2051,7 @@ impl ChatWidget { models_manager, feedback, is_first_run, + feedback_audience, model, otel_manager, } = common; @@ -2143,6 +2148,7 @@ impl ChatWidget { last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, + feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; @@ -2186,6 +2192,7 @@ impl ChatWidget { models_manager, feedback, is_first_run, + feedback_audience, model, otel_manager, } = common; @@ -2281,6 +2288,7 @@ impl ChatWidget { last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, + feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; @@ -2312,6 +2320,7 @@ impl ChatWidget { auth_manager, models_manager, feedback, + feedback_audience, model, otel_manager, .. @@ -2408,6 +2417,7 @@ impl ChatWidget { last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, + feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d8f22dc8e3..a0e8269b58 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8,6 +8,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::LocalImageAttachment; use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; @@ -716,6 +717,7 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, + feedback_audience: FeedbackAudience::External, model: Some(resolved_model), otel_manager, }; @@ -837,6 +839,7 @@ async fn make_chatwidget_manual( last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), + feedback_audience: FeedbackAudience::External, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, }; @@ -2309,6 +2312,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, + feedback_audience: FeedbackAudience::External, model: Some(resolved_model.clone()), otel_manager, }; @@ -2353,6 +2357,7 @@ async fn experimental_mode_plan_applies_on_startup() { models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, + feedback_audience: FeedbackAudience::External, model: Some(resolved_model.clone()), otel_manager, };