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`
This commit is contained in:
Josh McKinney
2026-01-29 18:12:46 -08:00
committed by GitHub
parent a9cf449a80
commit 36f2fe8af9
5 changed files with 154 additions and 33 deletions

View File

@@ -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<UpdateAction>,
@@ -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(),

View File

@@ -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<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
// UI state
textarea: TextArea,
@@ -52,6 +65,7 @@ impl FeedbackNoteView {
rollout_path: Option<PathBuf>,
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<String> {
fn issue_url_for_category(
category: FeedbackCategory,
thread_id: &str,
feedback_audience: FeedbackAudience,
) -> Option<String> {
// 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::<AppEvent>();
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())
);
}
}

View File

@@ -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;

View File

@@ -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<ModelsManager>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
pub(crate) feedback_audience: FeedbackAudience,
pub(crate) model: Option<String>,
pub(crate) otel_manager: OtelManager,
}
@@ -558,6 +560,7 @@ pub(crate) struct ChatWidget {
last_rendered_width: std::cell::Cell<Option<usize>>,
// Feedback sink for /feedback
feedback: codex_feedback::CodexFeedback,
feedback_audience: FeedbackAudience,
// Current session rollout path (if known)
current_rollout_path: Option<PathBuf>,
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,
};

View File

@@ -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,
};