mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user