mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Add local diagnostic ZIP saves to the TUI (#60542)
## What changed - Let users press `Ctrl+S` in the report note editor to save a diagnostic ZIP locally without sending it to OpenAI, including when uploads are disabled. Keep `Enter` for sending reports when uploads are enabled. - Apply the attachment consent choice to both destinations, clarify the disclosures, and show the saved archive path on completion. - Require a local app server for ZIP saves, report unsupported servers with an upgrade hint, and stop before collecting a note when no report destination is available. ## Testing Add coverage for ZIP creation with and without log consent, no upload requests on save success or failure, destination availability, keyboard actions, and saved-path receipts. Update report UI snapshots. GitOrigin-RevId: cbc0142988840e5e3f6475f31678915bf25d56a2
This commit is contained in:
@@ -4,10 +4,12 @@
|
||||
//! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so
|
||||
//! the main event loop remains single-threaded.
|
||||
|
||||
use super::feedback_upload::fetch_feedback_upload;
|
||||
use super::feedback_upload::deliver_feedback;
|
||||
use super::plugin_mentions::fetch_plugin_mentions;
|
||||
use super::*;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event::FeedbackDestination;
|
||||
use crate::app_event::FeedbackReceipt;
|
||||
use crate::app_info::app_info_from_api;
|
||||
use crate::chatwidget::ThreadUsageOutcome;
|
||||
use crate::config_update::format_config_error;
|
||||
@@ -633,30 +635,34 @@ impl App {
|
||||
reason: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
include_logs: bool,
|
||||
destination: FeedbackDestination,
|
||||
) {
|
||||
if destination == FeedbackDestination::LocalZip && app_server.uses_remote_workspace() {
|
||||
self.chat_widget.add_info_message(
|
||||
"Saving a feedback ZIP requires a local app server.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let origin_thread_id = self.chat_widget.thread_id();
|
||||
let rollout_path = if include_logs {
|
||||
self.chat_widget.rollout_path()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let params = build_feedback_upload_params(
|
||||
let params = build_feedback_params(
|
||||
origin_thread_id,
|
||||
rollout_path,
|
||||
self.chat_widget.rollout_path(),
|
||||
category,
|
||||
reason,
|
||||
turn_id,
|
||||
include_logs,
|
||||
destination,
|
||||
);
|
||||
let codex_home = app_server.codex_home_path(&self.config.codex_home);
|
||||
let feedback = self.feedback.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fetch_feedback_upload(request_handle, codex_home, params, feedback)
|
||||
.await
|
||||
.map(|response| response.thread_id)
|
||||
.map_err(|err| err.to_string());
|
||||
let result =
|
||||
deliver_feedback(request_handle, codex_home, params, feedback, destination)
|
||||
.await
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
app_event_tx.send(AppEvent::FeedbackSubmitted {
|
||||
origin_thread_id,
|
||||
category,
|
||||
@@ -668,7 +674,7 @@ impl App {
|
||||
|
||||
pub(super) fn handle_feedback_thread_event(&mut self, event: FeedbackThreadEvent) {
|
||||
match event.result {
|
||||
Ok(thread_id) => {
|
||||
Ok(FeedbackReceipt::Uploaded { thread_id }) => {
|
||||
self.chat_widget
|
||||
.add_to_history(crate::bottom_pane::feedback_success_cell(
|
||||
event.category,
|
||||
@@ -677,11 +683,16 @@ impl App {
|
||||
event.feedback_audience,
|
||||
))
|
||||
}
|
||||
Ok(FeedbackReceipt::Saved { path }) => {
|
||||
self.chat_widget
|
||||
.add_to_history(history_cell::new_info_event(
|
||||
format!("Feedback ZIP saved to {path}"),
|
||||
/*hint*/ None,
|
||||
))
|
||||
}
|
||||
Err(err) => self
|
||||
.chat_widget
|
||||
.add_to_history(history_cell::new_error_event(format!(
|
||||
"Failed to upload feedback: {err}"
|
||||
))),
|
||||
.add_to_history(history_cell::new_error_event(err)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,7 +735,7 @@ impl App {
|
||||
origin_thread_id: Option<ThreadId>,
|
||||
category: FeedbackCategory,
|
||||
include_logs: bool,
|
||||
result: Result<String, String>,
|
||||
result: Result<FeedbackReceipt, String>,
|
||||
) {
|
||||
let event = FeedbackThreadEvent {
|
||||
category,
|
||||
@@ -1296,15 +1307,18 @@ pub(super) async fn write_hook_enabled(
|
||||
.wrap_err("config/batchWrite failed while updating hook enablement in TUI")
|
||||
}
|
||||
|
||||
pub(super) fn build_feedback_upload_params(
|
||||
pub(super) fn build_feedback_params(
|
||||
origin_thread_id: Option<ThreadId>,
|
||||
rollout_path: Option<PathBuf>,
|
||||
category: FeedbackCategory,
|
||||
reason: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
include_logs: bool,
|
||||
destination: FeedbackDestination,
|
||||
) -> FeedbackUploadParams {
|
||||
let extra_log_files = if include_logs {
|
||||
// The app-server `feedback/save` RPC handler selects persisted transcripts by thread ID.
|
||||
// The TUI can already know a future rollout path before the first turn writes it.
|
||||
let extra_log_files = if include_logs && destination == FeedbackDestination::OpenAi {
|
||||
rollout_path.map(|rollout_path| vec![rollout_path])
|
||||
} else {
|
||||
None
|
||||
@@ -1696,17 +1710,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_feedback_upload_params_includes_thread_id_and_rollout_path() {
|
||||
fn build_feedback_params_includes_thread_id_and_rollout_path() {
|
||||
let thread_id = ThreadId::new();
|
||||
let rollout_path = PathBuf::from("/tmp/rollout.jsonl");
|
||||
|
||||
let params = build_feedback_upload_params(
|
||||
let params = build_feedback_params(
|
||||
Some(thread_id),
|
||||
Some(rollout_path.clone()),
|
||||
FeedbackCategory::SafetyCheck,
|
||||
Some("needs follow-up".to_string()),
|
||||
Some("turn-123".to_string()),
|
||||
/*include_logs*/ true,
|
||||
FeedbackDestination::OpenAi,
|
||||
);
|
||||
|
||||
assert_eq!(params.classification, "safety_check");
|
||||
@@ -1725,14 +1740,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_feedback_upload_params_omits_rollout_path_without_logs() {
|
||||
let params = build_feedback_upload_params(
|
||||
fn build_feedback_params_omits_rollout_path_without_logs() {
|
||||
let params = build_feedback_params(
|
||||
/*origin_thread_id*/ None,
|
||||
Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
FeedbackCategory::GoodResult,
|
||||
/*reason*/ None,
|
||||
/*turn_id*/ None,
|
||||
/*include_logs*/ false,
|
||||
FeedbackDestination::OpenAi,
|
||||
);
|
||||
|
||||
assert_eq!(params.classification, "good_result");
|
||||
|
||||
@@ -2703,22 +2703,29 @@ impl App {
|
||||
AppEvent::ApplyPermissionShortcut { thread_id, selection } => {
|
||||
self.apply_permission_shortcut(app_server, thread_id, selection).await;
|
||||
}
|
||||
AppEvent::OpenFeedback => self.chat_widget.open_feedback(app_server.thread_params_mode()),
|
||||
AppEvent::OpenFeedbackNote {
|
||||
category,
|
||||
include_logs,
|
||||
} => {
|
||||
self.chat_widget.open_feedback_note(category, include_logs, self.feedback_audience);
|
||||
self.chat_widget.open_feedback_note(
|
||||
category,
|
||||
include_logs,
|
||||
self.feedback_audience,
|
||||
!app_server.uses_remote_workspace(),
|
||||
);
|
||||
}
|
||||
AppEvent::OpenFeedbackConsent { category } => {
|
||||
self.chat_widget.open_feedback_consent(category);
|
||||
self.chat_widget.open_feedback_consent(category, !app_server.uses_remote_workspace());
|
||||
}
|
||||
AppEvent::SubmitFeedback {
|
||||
category,
|
||||
reason,
|
||||
turn_id,
|
||||
include_logs,
|
||||
destination,
|
||||
} => {
|
||||
self.submit_feedback(app_server, category, reason, turn_id, include_logs);
|
||||
self.submit_feedback(app_server, category, reason, turn_id, include_logs, destination);
|
||||
}
|
||||
AppEvent::FeedbackSubmitted {
|
||||
origin_thread_id,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
//! Stage bounded TUI diagnostics on the app-server host for a single feedback upload.
|
||||
//! Log consent gates all staging; cleanup runs after staging or upload failures too.
|
||||
//! Stage consented TUI diagnostics for feedback uploads and local ZIP saves.
|
||||
|
||||
use crate::app_event::FeedbackDestination;
|
||||
use crate::app_event::FeedbackReceipt;
|
||||
use crate::app_server_session::fs::AppServerFileSystem;
|
||||
use crate::app_server_session::is_method_unsupported;
|
||||
use codex_app_server_client::AppServerPath;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_client::TypedRequestError;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::FeedbackSaveParams;
|
||||
use codex_app_server_protocol::FeedbackSaveResponse;
|
||||
use codex_app_server_protocol::FeedbackUploadParams;
|
||||
use codex_app_server_protocol::FeedbackUploadResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_utils_path_uri::LegacyAppPathString;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use color_eyre::eyre::eyre;
|
||||
@@ -17,25 +23,30 @@ use uuid::Uuid;
|
||||
|
||||
// Base64 adds only a third to this size, keeping fs/writeFile below the 16 MiB frame limit.
|
||||
const MAX_CLIENT_LOG_SIZE: usize = 1024 * 1024;
|
||||
const FEEDBACK_DIR: &str = "feedback";
|
||||
|
||||
pub(super) async fn fetch_feedback_upload(
|
||||
pub(super) async fn deliver_feedback(
|
||||
request_handle: AppServerRequestHandle,
|
||||
codex_home: Option<AppServerPath>,
|
||||
mut params: FeedbackUploadParams,
|
||||
feedback: CodexFeedback,
|
||||
) -> Result<FeedbackUploadResponse> {
|
||||
destination: FeedbackDestination,
|
||||
) -> Result<FeedbackReceipt> {
|
||||
let fs = AppServerFileSystem { request_handle };
|
||||
let staging_dir = codex_home.filter(|_| params.include_logs).map(|home| {
|
||||
home.join("tmp")
|
||||
.join(format!("tui-feedback-{}", Uuid::new_v4()))
|
||||
});
|
||||
let staging_dir = codex_home
|
||||
.as_ref()
|
||||
.filter(|_| params.include_logs)
|
||||
.map(|home| {
|
||||
home.join("tmp")
|
||||
.join(format!("tui-feedback-{}", Uuid::new_v4()))
|
||||
});
|
||||
if params.include_logs {
|
||||
let staged = async {
|
||||
let directory = staging_dir.as_ref().ok_or_else(|| {
|
||||
eyre!("App server did not report $CODEX_HOME; cannot stage feedback logs")
|
||||
})?;
|
||||
// These RPCs use the app-server's local filesystem, where feedback/upload
|
||||
// reads extraLogFiles, independently of the thread's executor environment.
|
||||
// These RPCs use the app-server's local filesystem, where both feedback methods
|
||||
// read extraLogFiles, independently of the thread's executor environment.
|
||||
fs.fs_create_directory_all_path(directory).await?;
|
||||
let path = directory.join("client-logs.txt");
|
||||
let mut logs = feedback
|
||||
@@ -60,12 +71,22 @@ pub(super) async fn fetch_feedback_upload(
|
||||
}
|
||||
}
|
||||
}
|
||||
let request_id = RequestId::String(format!("feedback-upload-{}", Uuid::new_v4()));
|
||||
let result = fs
|
||||
.request_handle
|
||||
.request_typed(ClientRequest::FeedbackUpload { request_id, params })
|
||||
.await
|
||||
.wrap_err("feedback/upload failed in TUI");
|
||||
let result = match destination {
|
||||
FeedbackDestination::OpenAi => fs
|
||||
.request_handle
|
||||
.request_typed::<FeedbackUploadResponse>(ClientRequest::FeedbackUpload {
|
||||
request_id: RequestId::String(format!("feedback-upload-{}", Uuid::new_v4())),
|
||||
params,
|
||||
})
|
||||
.await
|
||||
.map(|response| FeedbackReceipt::Uploaded {
|
||||
thread_id: response.thread_id,
|
||||
})
|
||||
.wrap_err("Failed to upload feedback"),
|
||||
FeedbackDestination::LocalZip => save_feedback_zip(&fs, codex_home.as_ref(), params)
|
||||
.await
|
||||
.wrap_err("Failed to save feedback ZIP"),
|
||||
};
|
||||
|
||||
if let Some(directory) = staging_dir
|
||||
&& let Err(err) = fs.fs_remove_path(&directory).await
|
||||
@@ -74,3 +95,41 @@ pub(super) async fn fetch_feedback_upload(
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn save_feedback_zip(
|
||||
fs: &AppServerFileSystem,
|
||||
codex_home: Option<&AppServerPath>,
|
||||
params: FeedbackUploadParams,
|
||||
) -> Result<FeedbackReceipt> {
|
||||
let home = codex_home
|
||||
.ok_or_else(|| eyre!("The app server did not report its Codex home directory"))?;
|
||||
let directory = home.join(FEEDBACK_DIR);
|
||||
fs.fs_create_directory_all_path(&directory)
|
||||
.await
|
||||
.wrap_err_with(|| format!("Could not create the feedback directory at {directory}"))?;
|
||||
let id = Uuid::new_v4();
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
|
||||
let suffix = id.as_fields().0;
|
||||
let path = directory.join(format!("codex-diagnostics-{timestamp}-{suffix:08x}.zip"));
|
||||
let response: FeedbackSaveResponse = fs
|
||||
.request_handle
|
||||
.request_typed(ClientRequest::FeedbackSave {
|
||||
request_id: RequestId::String(format!("feedback-save-{id}")),
|
||||
params: FeedbackSaveParams {
|
||||
feedback: params,
|
||||
save_path: LegacyAppPathString::from_string(path.as_str()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(|err| match &err {
|
||||
TypedRequestError::Server { source, .. }
|
||||
if is_method_unsupported(source, "feedback/save") =>
|
||||
{
|
||||
eyre!("Update the Codex server to save a feedback ZIP")
|
||||
}
|
||||
_ => color_eyre::Report::from(err),
|
||||
})?;
|
||||
Ok(FeedbackReceipt::Saved {
|
||||
path: response.saved_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/app/tests.rs
|
||||
expression: next_history_message(&mut events)
|
||||
---
|
||||
• Feedback ZIP saved to /home/dev/.codex/feedback/codex-diagnostics-2026-11-09T23-53-00Z-a1b2c3d4.zip
|
||||
@@ -4958,7 +4958,9 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
origin_thread_id: Some(side_thread_id),
|
||||
category: FeedbackCategory::Bug,
|
||||
include_logs: false,
|
||||
result: Ok("uploaded-thread".to_string()),
|
||||
result: Ok(crate::app_event::FeedbackReceipt::Uploaded {
|
||||
thread_id: "uploaded-thread".to_string(),
|
||||
}),
|
||||
});
|
||||
app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id)
|
||||
.await?;
|
||||
@@ -10002,23 +10004,25 @@ async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell(
|
||||
if staging_fails {
|
||||
std::fs::write(&tmp, "not a directory")?;
|
||||
}
|
||||
let mut params = background_requests::build_feedback_upload_params(
|
||||
let mut params = background_requests::build_feedback_params(
|
||||
/*origin_thread_id*/ None,
|
||||
Some(PathBuf::from("existing-rollout.jsonl")),
|
||||
FeedbackCategory::Bug,
|
||||
/*reason*/ None,
|
||||
/*turn_id*/ None,
|
||||
include_logs,
|
||||
crate::app_event::FeedbackDestination::OpenAi,
|
||||
);
|
||||
// Reject before network upload, after the client has staged its diagnostics.
|
||||
params.thread_id = Some("invalid-thread-id".into());
|
||||
let error = feedback_upload::fetch_feedback_upload(
|
||||
let error = feedback_upload::deliver_feedback(
|
||||
server.request_handle(),
|
||||
Some(AppServerPath::from_app_server(
|
||||
home.path().display().to_string(),
|
||||
)),
|
||||
params,
|
||||
app.feedback.clone(),
|
||||
crate::app_event::FeedbackDestination::OpenAi,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -10071,7 +10075,7 @@ async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell(
|
||||
/*origin_thread_id*/ None,
|
||||
FeedbackCategory::Bug,
|
||||
/*include_logs*/ true,
|
||||
Err("boom".to_string()),
|
||||
Err("Failed to upload feedback: boom".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -10086,6 +10090,112 @@ async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_feedback_saves_a_zip_with_only_consented_logs_and_never_uploads() -> Result<()> {
|
||||
use crate::app_event::FeedbackDestination;
|
||||
use crate::app_event::FeedbackReceipt;
|
||||
|
||||
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
|
||||
let (server, requests, proxy) =
|
||||
session_lifecycle_requests::start_recording_remote_app_server(&app.config).await?;
|
||||
// A queued local save must also respect the connection active at dispatch.
|
||||
app.submit_feedback(
|
||||
&server,
|
||||
FeedbackCategory::Bug,
|
||||
/*reason*/ None,
|
||||
/*turn_id*/ None,
|
||||
/*include_logs*/ true,
|
||||
FeedbackDestination::LocalZip,
|
||||
);
|
||||
assert!(next_history_message(&mut events).contains("requires a local app server"));
|
||||
|
||||
let home = tempdir()?;
|
||||
let save = |directory: &std::path::Path, include_logs| {
|
||||
let params = background_requests::build_feedback_params(
|
||||
/*origin_thread_id*/ None,
|
||||
Some(directory.join("rollout-not-yet-created.jsonl")),
|
||||
FeedbackCategory::Bug,
|
||||
Some("Review before sharing".into()),
|
||||
/*turn_id*/ None,
|
||||
include_logs,
|
||||
FeedbackDestination::LocalZip,
|
||||
);
|
||||
feedback_upload::deliver_feedback(
|
||||
server.request_handle(),
|
||||
Some(AppServerPath::from_app_server(
|
||||
directory.display().to_string(),
|
||||
)),
|
||||
params,
|
||||
app.feedback.clone(),
|
||||
FeedbackDestination::LocalZip,
|
||||
)
|
||||
};
|
||||
for include_logs in [true, false] {
|
||||
let receipt = save(home.path(), include_logs).await?;
|
||||
let FeedbackReceipt::Saved { path } = receipt else {
|
||||
panic!("expected a saved ZIP");
|
||||
};
|
||||
let path = PathBuf::from(path);
|
||||
assert_eq!(path.parent(), Some(home.path().join("feedback").as_path()));
|
||||
assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("zip"));
|
||||
let name = path.file_stem().unwrap().to_str().unwrap();
|
||||
let (timestamp, suffix) = name
|
||||
.strip_prefix("codex-diagnostics-")
|
||||
.unwrap()
|
||||
.rsplit_once('-')
|
||||
.unwrap();
|
||||
chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H-%M-%SZ")?;
|
||||
assert_eq!(suffix.len(), 8);
|
||||
u32::from_str_radix(suffix, 16)?;
|
||||
assert!(std::fs::read(&path)?.starts_with(b"PK"));
|
||||
let recorded = std::mem::take(&mut *requests.lock().unwrap());
|
||||
let save = recorded
|
||||
.iter()
|
||||
.find(|r| r.method == "feedback/save")
|
||||
.unwrap();
|
||||
let save: codex_app_server_protocol::FeedbackSaveParams =
|
||||
serde_json::from_value(save.params.clone().unwrap())?;
|
||||
assert_eq!(save.feedback.include_logs, include_logs);
|
||||
assert_eq!(
|
||||
recorded.iter().any(|r| r.method == "fs/writeFile"),
|
||||
include_logs
|
||||
);
|
||||
assert!(!recorded.iter().any(|r| r.method == "feedback/upload"));
|
||||
}
|
||||
// A destination this server cannot write must never turn into an upload.
|
||||
let invalid_home = home.path().join("plain-file");
|
||||
std::fs::write(&invalid_home, "not a directory")?;
|
||||
let error = save(&invalid_home, /*include_logs*/ false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = format!("{error:#}");
|
||||
assert!(
|
||||
message.starts_with("Failed to save feedback ZIP: Could not create the feedback directory")
|
||||
);
|
||||
assert!(message.contains("fs/createDirectory"));
|
||||
let recorded = std::mem::take(&mut *requests.lock().unwrap());
|
||||
assert!(!recorded.iter().any(|r| r.method == "feedback/upload"));
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_saved_receipt_shows_the_full_archive_path() {
|
||||
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
|
||||
app.handle_feedback_submitted(
|
||||
/*origin_thread_id*/ None,
|
||||
FeedbackCategory::Bug,
|
||||
/*include_logs*/ false,
|
||||
Ok(crate::app_event::FeedbackReceipt::Saved {
|
||||
path: "/home/dev/.codex/feedback/codex-diagnostics-2026-11-09T23-53-00Z-a1b2c3d4.zip"
|
||||
.into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
insta::assert_snapshot!("feedback_saved_receipt", next_history_message(&mut events));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_submission_for_inactive_thread_replays_into_origin_thread() {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
@@ -10117,7 +10227,9 @@ async fn feedback_submission_for_inactive_thread_replays_into_origin_thread() {
|
||||
Some(origin_thread_id),
|
||||
FeedbackCategory::Bug,
|
||||
/*include_logs*/ true,
|
||||
Ok("uploaded-thread".to_string()),
|
||||
Ok(crate::app_event::FeedbackReceipt::Uploaded {
|
||||
thread_id: "uploaded-thread".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(super) struct FeedbackThreadEvent {
|
||||
pub(super) category: FeedbackCategory,
|
||||
pub(super) include_logs: bool,
|
||||
pub(super) feedback_audience: FeedbackAudience,
|
||||
pub(super) result: Result<String, String>,
|
||||
pub(super) result: Result<crate::app_event::FeedbackReceipt, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -1771,13 +1771,16 @@ pub(crate) enum AppEvent {
|
||||
crate::bottom_pane::user_verification::UserVerificationRequest,
|
||||
),
|
||||
|
||||
/// Open feedback for the active connection.
|
||||
OpenFeedback,
|
||||
|
||||
/// Open the feedback note entry overlay after the user selects a category.
|
||||
OpenFeedbackNote {
|
||||
category: FeedbackCategory,
|
||||
include_logs: bool,
|
||||
},
|
||||
|
||||
/// Open the upload consent popup for feedback after selecting a category.
|
||||
/// Open the attachment consent popup for feedback after selecting a category.
|
||||
OpenFeedbackConsent {
|
||||
category: FeedbackCategory,
|
||||
},
|
||||
@@ -1788,14 +1791,15 @@ pub(crate) enum AppEvent {
|
||||
reason: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
include_logs: bool,
|
||||
destination: FeedbackDestination,
|
||||
},
|
||||
|
||||
/// Result of a feedback upload request initiated by the TUI.
|
||||
/// Delivery result for the thread that requested feedback.
|
||||
FeedbackSubmitted {
|
||||
origin_thread_id: Option<ThreadId>,
|
||||
category: FeedbackCategory,
|
||||
include_logs: bool,
|
||||
result: Result<String, String>,
|
||||
result: Result<FeedbackReceipt, String>,
|
||||
},
|
||||
|
||||
/// Launch the external editor after a normal draw has completed.
|
||||
@@ -1981,6 +1985,18 @@ pub(crate) enum FeedbackCategory {
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FeedbackDestination {
|
||||
OpenAi,
|
||||
LocalZip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum FeedbackReceipt {
|
||||
Uploaded { thread_id: String },
|
||||
Saved { path: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum SideStartMode {
|
||||
Reuse,
|
||||
|
||||
@@ -334,10 +334,9 @@ pub(crate) async fn request_thread_start_with_history_fallback(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool {
|
||||
pub(crate) fn is_method_unsupported(source: &JSONRPCErrorError, method: &str) -> bool {
|
||||
source.code == JSONRPC_METHOD_NOT_FOUND
|
||||
|| (source.code == JSONRPC_INVALID_REQUEST
|
||||
&& source.message.contains(THREAD_SETTINGS_UPDATE_METHOD))
|
||||
|| (source.code == JSONRPC_INVALID_REQUEST && source.message.contains(method))
|
||||
}
|
||||
|
||||
/// Data collected during the TUI bootstrap phase that the main event loop
|
||||
@@ -1736,7 +1735,7 @@ impl AppServerSession {
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(TypedRequestError::Server { source, .. })
|
||||
if is_thread_settings_update_unsupported(&source) =>
|
||||
if is_method_unsupported(&source, THREAD_SETTINGS_UPDATE_METHOD) =>
|
||||
{
|
||||
// Older remote app servers can reject this experimental method as
|
||||
// method-not-found, experimental-capability-gated, or an unknown
|
||||
@@ -3504,7 +3503,7 @@ mod tests {
|
||||
message: message.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
is_thread_settings_update_unsupported(&source),
|
||||
is_method_unsupported(&source, THREAD_SETTINGS_UPDATE_METHOD),
|
||||
expected,
|
||||
"{message}"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Collects a feedback note with audience-specific disclosure and independent log consent.
|
||||
//! The editor and submission controls remain visible when the disclosure must scroll.
|
||||
//! Unbracketed paste bursts insert newlines; Enter submits after the burst ends.
|
||||
//! Ctrl+S saves on local connections. Enter sends when uploads are enabled and the paste burst has ended.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -20,6 +20,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::FeedbackCategory;
|
||||
use crate::app_event::FeedbackDestination;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
@@ -32,10 +33,10 @@ use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::feedback_view::FeedbackAudience;
|
||||
use super::paste_burst::PasteBurst;
|
||||
use super::popup_consts::accept_cancel_hint_line;
|
||||
use super::textarea::TextArea;
|
||||
use super::textarea::TextAreaState;
|
||||
|
||||
const SAVE_LOCALLY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('s'));
|
||||
const FEEDBACK_POLICY_URL: &str = "https://help.openai.com/en/articles/5722486-how-your-data-is-used-to-improve-model-performance";
|
||||
const EMPLOYEE_FEEDBACK_FAQ_URL: &str = "https://go/codex-employee-feedback-faq";
|
||||
|
||||
@@ -51,7 +52,8 @@ pub(crate) struct FeedbackNoteView {
|
||||
turn_id: Option<String>,
|
||||
app_event_tx: AppEventSender,
|
||||
include_logs: bool,
|
||||
feedback_audience: FeedbackAudience,
|
||||
upload_audience: Option<FeedbackAudience>,
|
||||
can_save: bool,
|
||||
|
||||
// UI state
|
||||
textarea: TextArea,
|
||||
@@ -68,14 +70,16 @@ impl FeedbackNoteView {
|
||||
turn_id: Option<String>,
|
||||
app_event_tx: AppEventSender,
|
||||
include_logs: bool,
|
||||
feedback_audience: FeedbackAudience,
|
||||
upload_audience: Option<FeedbackAudience>,
|
||||
can_save: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
category,
|
||||
turn_id,
|
||||
app_event_tx,
|
||||
include_logs,
|
||||
feedback_audience,
|
||||
upload_audience,
|
||||
can_save,
|
||||
textarea: TextArea::new(),
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
paste_burst: PasteBurst::default(),
|
||||
@@ -85,7 +89,7 @@ impl FeedbackNoteView {
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) {
|
||||
fn submit(&mut self, destination: FeedbackDestination) {
|
||||
let note = self.textarea.text().trim().to_string();
|
||||
let reason = if note.is_empty() { None } else { Some(note) };
|
||||
self.app_event_tx.send(AppEvent::SubmitFeedback {
|
||||
@@ -93,12 +97,18 @@ impl FeedbackNoteView {
|
||||
reason,
|
||||
turn_id: self.turn_id.clone(),
|
||||
include_logs: self.include_logs,
|
||||
destination,
|
||||
});
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) {
|
||||
match key_event {
|
||||
key if SAVE_LOCALLY.is_press(key) => {
|
||||
if self.can_save {
|
||||
self.submit(FeedbackDestination::LocalZip);
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::PageUp,
|
||||
..
|
||||
@@ -132,10 +142,10 @@ impl FeedbackNoteView {
|
||||
if self.paste_burst.direct_insert_newline_should_insert(now) {
|
||||
self.paste_burst.extend_window(now);
|
||||
self.textarea.insert_str("\n");
|
||||
} else if modifiers == KeyModifiers::NONE {
|
||||
self.submit();
|
||||
} else {
|
||||
} else if modifiers != KeyModifiers::NONE {
|
||||
self.textarea.input(key_event);
|
||||
} else if self.upload_audience.is_some() {
|
||||
self.submit(FeedbackDestination::OpenAi);
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -261,11 +271,13 @@ impl Renderable for FeedbackNoteView {
|
||||
buf,
|
||||
);
|
||||
}
|
||||
let disclosure_url = match self.feedback_audience {
|
||||
FeedbackAudience::OpenAiEmployee => EMPLOYEE_FEEDBACK_FAQ_URL,
|
||||
FeedbackAudience::External => FEEDBACK_POLICY_URL,
|
||||
};
|
||||
mark_underlined_hyperlink(buf, intro_area, disclosure_url);
|
||||
if let Some(audience) = self.upload_audience {
|
||||
let url = match audience {
|
||||
FeedbackAudience::OpenAiEmployee => EMPLOYEE_FEEDBACK_FAQ_URL,
|
||||
FeedbackAudience::External => FEEDBACK_POLICY_URL,
|
||||
};
|
||||
mark_underlined_hyperlink(buf, intro_area, url);
|
||||
}
|
||||
|
||||
// Input line
|
||||
if input_area.width >= 2 {
|
||||
@@ -331,13 +343,28 @@ impl Renderable for FeedbackNoteView {
|
||||
|
||||
let hint_y = hint_blank_y.saturating_add(hint_spacing);
|
||||
if hint_y < area.y.saturating_add(area.height) {
|
||||
Paragraph::new(accept_cancel_hint_line(
|
||||
Some(key_hint::plain(KeyCode::Enter).into()),
|
||||
"to submit",
|
||||
Some(key_hint::plain(KeyCode::Esc).into()),
|
||||
"to cancel",
|
||||
))
|
||||
.render(
|
||||
let action_line = |save: &str, send: &str| {
|
||||
let mut actions = Vec::new();
|
||||
if self.can_save {
|
||||
actions.push((SAVE_LOCALLY.display_label(), save.to_owned()));
|
||||
}
|
||||
if self.upload_audience.is_some() {
|
||||
actions.push((
|
||||
key_hint::plain(KeyCode::Enter).display_label(),
|
||||
send.to_owned(),
|
||||
));
|
||||
}
|
||||
actions.push((
|
||||
key_hint::plain(KeyCode::Esc).display_label(),
|
||||
"cancel".into(),
|
||||
));
|
||||
super::footer::footer_hint_items_line(&actions)
|
||||
};
|
||||
let mut hints = action_line("save ZIP", "send to OpenAI");
|
||||
if hints.width() > usize::from(area.width) {
|
||||
hints = action_line("save", "send");
|
||||
}
|
||||
Paragraph::new(hints).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: hint_y,
|
||||
@@ -379,10 +406,11 @@ impl FeedbackNoteView {
|
||||
IntroContent::Full => vec![title.bold().into(), "".into()],
|
||||
IntroContent::DisclosureOnly => Vec::new(),
|
||||
};
|
||||
match self.feedback_audience {
|
||||
FeedbackAudience::OpenAiEmployee => {
|
||||
match self.upload_audience {
|
||||
None => lines.push("Feedback uploads are disabled.".dim().into()),
|
||||
Some(FeedbackAudience::OpenAiEmployee) => {
|
||||
lines.push(
|
||||
"Your data may be used to improve our models and products"
|
||||
"Feedback sent to OpenAI may be used to improve our models and products"
|
||||
.bold()
|
||||
.into(),
|
||||
);
|
||||
@@ -392,12 +420,20 @@ impl FeedbackNoteView {
|
||||
". Please avoid sharing tented information under NDA, or privileged, HR-related, or sensitive personal information.".dim(),
|
||||
]));
|
||||
}
|
||||
FeedbackAudience::External => lines.push(Line::from(vec![
|
||||
"Your feedback can be used to improve ChatGPT. ".dim(),
|
||||
Some(FeedbackAudience::External) => lines.push(Line::from(vec![
|
||||
"Feedback sent to OpenAI can be used to improve ChatGPT. ".dim(),
|
||||
"Learn more".fg(crate::style::accent_color()).underlined(),
|
||||
".".dim(),
|
||||
])),
|
||||
}
|
||||
if self.can_save {
|
||||
let disclosure = if self.upload_audience.is_some() {
|
||||
"You can also save feedback to a ZIP file, which is not sent to OpenAI."
|
||||
} else {
|
||||
"You can save feedback to a ZIP file, which is not sent to OpenAI."
|
||||
};
|
||||
lines.push(disclosure.dim().into());
|
||||
}
|
||||
word_wrap_lines(
|
||||
lines,
|
||||
RtOptions::new(usize::from(width.max(1)))
|
||||
|
||||
@@ -43,7 +43,8 @@ fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
|
||||
/*turn_id*/ None,
|
||||
tx,
|
||||
/*include_logs*/ true,
|
||||
FeedbackAudience::External,
|
||||
Some(FeedbackAudience::External),
|
||||
/*can_save*/ true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ fn feedback_disclosure_matches_audience_at_normal_and_narrow_widths() {
|
||||
),
|
||||
] {
|
||||
let mut view = make_view(FeedbackCategory::Bug);
|
||||
view.feedback_audience = audience;
|
||||
view.upload_audience = Some(audience);
|
||||
for width in [80, 40] {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
@@ -116,6 +117,12 @@ fn feedback_disclosure_matches_audience_at_normal_and_narrow_widths() {
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut remote_view = make_view(FeedbackCategory::Bug);
|
||||
remote_view.can_save = false;
|
||||
rendered.push(format!(
|
||||
"remote server (80 columns)\n{}",
|
||||
render(&remote_view, /*width*/ 80)
|
||||
));
|
||||
insta::assert_snapshot!("feedback_disclosures", rendered.join("\n\n"));
|
||||
}
|
||||
|
||||
@@ -124,7 +131,7 @@ fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() {
|
||||
let mut rendered = Vec::new();
|
||||
for (width, height) in [(40, 12), (80, 8)] {
|
||||
let mut view = make_view(FeedbackCategory::Bug);
|
||||
view.feedback_audience = FeedbackAudience::OpenAiEmployee;
|
||||
view.upload_audience = Some(FeedbackAudience::OpenAiEmployee);
|
||||
view.handle_paste("A visible note.".to_string());
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
for (stage, key) in [
|
||||
@@ -139,7 +146,7 @@ fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() {
|
||||
view.render(area, &mut buf);
|
||||
let text = render_buffer(area, &buf);
|
||||
assert!(text.contains("A visible note."));
|
||||
assert!(text.contains("enter to submit"));
|
||||
assert!(text.contains("enter send"));
|
||||
assert!(
|
||||
view.cursor_pos(area)
|
||||
.is_some_and(|(x, y)| x < width && y < height)
|
||||
@@ -157,7 +164,7 @@ fn feedback_disclosure_scrolls_without_hiding_input_in_short_panes() {
|
||||
#[test]
|
||||
fn feedback_disclosure_keeps_editor_visible_in_four_rows() {
|
||||
let mut view = make_view(FeedbackCategory::Bug);
|
||||
view.feedback_audience = FeedbackAudience::OpenAiEmployee;
|
||||
view.upload_audience = Some(FeedbackAudience::OpenAiEmployee);
|
||||
view.handle_paste("A visible note.".to_string());
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4,
|
||||
@@ -170,14 +177,20 @@ fn feedback_disclosure_keeps_editor_visible_in_four_rows() {
|
||||
.is_some_and(|(x, y)| area.contains((x, y).into()))
|
||||
);
|
||||
let initial = render_buffer(area, &buf);
|
||||
assert!(initial.contains("Your data may be used to improve our models and products"));
|
||||
assert!(
|
||||
initial.contains("Feedback sent to OpenAI may be used to improve our models and products")
|
||||
);
|
||||
let mut saw_sensitive_information_notice = false;
|
||||
for _ in view.intro_lines(area.width, IntroContent::Full) {
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
|
||||
buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
saw_sensitive_information_notice |=
|
||||
render_buffer(area, &buf).contains("personal information.");
|
||||
}
|
||||
let scrolled = render_buffer(area, &buf);
|
||||
assert!(scrolled.contains("personal information."));
|
||||
assert!(saw_sensitive_information_notice);
|
||||
assert!(scrolled.contains("which is not sent to OpenAI"));
|
||||
insta::assert_snapshot!(
|
||||
"feedback_disclosure_four_rows",
|
||||
format!("{initial}\n\n{scrolled}")
|
||||
@@ -186,15 +199,14 @@ fn feedback_disclosure_keeps_editor_visible_in_four_rows() {
|
||||
let narrow_area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
|
||||
);
|
||||
for (audience, disclosure) in [
|
||||
(FeedbackAudience::OpenAiEmployee, "Your data"),
|
||||
(FeedbackAudience::External, "Your feedback"),
|
||||
] {
|
||||
for audience in [FeedbackAudience::OpenAiEmployee, FeedbackAudience::External] {
|
||||
let mut view = make_view(FeedbackCategory::SafetyCheck);
|
||||
view.feedback_audience = audience;
|
||||
view.upload_audience = Some(audience);
|
||||
let mut buf = Buffer::empty(narrow_area);
|
||||
view.render(narrow_area, &mut buf);
|
||||
assert!(render_buffer(narrow_area, &buf).contains(disclosure));
|
||||
let text = render_buffer(narrow_area, &buf);
|
||||
assert!(text.contains("Feedback sent to"));
|
||||
assert!(text.contains("save · enter send"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +220,8 @@ fn feedback_paste_burst_preserves_multiline_note_until_submit() {
|
||||
/*turn_id*/ None,
|
||||
AppEventSender::new(tx),
|
||||
include_logs,
|
||||
FeedbackAudience::External,
|
||||
Some(FeedbackAudience::External),
|
||||
/*can_save*/ true,
|
||||
);
|
||||
let now = Instant::now();
|
||||
for (index, ch) in note.chars().enumerate() {
|
||||
@@ -240,6 +253,7 @@ fn feedback_paste_burst_preserves_multiline_note_until_submit() {
|
||||
reason,
|
||||
turn_id,
|
||||
include_logs: submitted_logs,
|
||||
destination: FeedbackDestination::OpenAi,
|
||||
} = rx.try_recv().expect("submit feedback event")
|
||||
else {
|
||||
panic!("expected feedback submission");
|
||||
@@ -270,7 +284,8 @@ fn feedback_bracketed_paste_does_not_reach_chat_composer() {
|
||||
/*turn_id*/ None,
|
||||
tx,
|
||||
include_logs,
|
||||
FeedbackAudience::External,
|
||||
Some(FeedbackAudience::External),
|
||||
/*can_save*/ true,
|
||||
)));
|
||||
// An explicit paste ends any preceding keystroke burst.
|
||||
pane.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
|
||||
@@ -298,7 +313,7 @@ fn feedback_bracketed_paste_does_not_reach_chat_composer() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_feedback_emits_submit_event_with_trimmed_note() {
|
||||
fn remote_save_shortcut_keeps_the_note_for_explicit_upload() {
|
||||
let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = FeedbackNoteView::new(
|
||||
@@ -306,9 +321,13 @@ fn submit_feedback_emits_submit_event_with_trimmed_note() {
|
||||
Some("turn-123".to_string()),
|
||||
tx,
|
||||
/*include_logs*/ true,
|
||||
FeedbackAudience::OpenAiEmployee,
|
||||
Some(FeedbackAudience::OpenAiEmployee),
|
||||
/*can_save*/ false,
|
||||
);
|
||||
view.textarea.insert_str(" something broke ");
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(!view.is_complete());
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
@@ -320,6 +339,7 @@ fn submit_feedback_emits_submit_event_with_trimmed_note() {
|
||||
reason: Some(reason),
|
||||
turn_id: Some(turn_id),
|
||||
include_logs: true,
|
||||
destination: FeedbackDestination::OpenAi,
|
||||
} if reason == "something broke" && turn_id == "turn-123"
|
||||
));
|
||||
assert_eq!(view.is_complete(), true);
|
||||
@@ -334,10 +354,11 @@ fn submit_feedback_omits_empty_note() {
|
||||
/*turn_id*/ None,
|
||||
tx,
|
||||
/*include_logs*/ false,
|
||||
FeedbackAudience::External,
|
||||
Some(FeedbackAudience::External),
|
||||
/*can_save*/ true,
|
||||
);
|
||||
|
||||
view.submit();
|
||||
view.handle_key_event(KeyCode::Enter.into());
|
||||
|
||||
let event = rx.try_recv().expect("submit feedback event");
|
||||
assert!(matches!(
|
||||
@@ -347,10 +368,45 @@ fn submit_feedback_omits_empty_note() {
|
||||
reason: None,
|
||||
turn_id: None,
|
||||
include_logs: false,
|
||||
destination: FeedbackDestination::OpenAi,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_shortcut_keeps_note_and_consent_with_or_without_uploads() {
|
||||
for audience in [None, Some(FeedbackAudience::External)] {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut view = FeedbackNoteView::new(
|
||||
FeedbackCategory::Bug,
|
||||
Some("turn-123".into()),
|
||||
AppEventSender::new(tx),
|
||||
/*include_logs*/ false,
|
||||
audience,
|
||||
/*can_save*/ true,
|
||||
);
|
||||
view.handle_paste("Review before sharing".into());
|
||||
if audience.is_none() {
|
||||
insta::assert_snapshot!(
|
||||
"feedback_note_uploads_disabled",
|
||||
render(&view, /*width*/ 60)
|
||||
);
|
||||
view.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(!view.is_complete());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert!(matches!(rx.try_recv(), Ok(AppEvent::SubmitFeedback {
|
||||
destination: FeedbackDestination::LocalZip,
|
||||
category: FeedbackCategory::Bug,
|
||||
reason: Some(note),
|
||||
turn_id: Some(turn),
|
||||
include_logs: false,
|
||||
}) if note == "Review before sharing" && turn == "turn-123"));
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_note_cancel_does_not_submit() {
|
||||
let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
|
||||
@@ -359,7 +415,8 @@ fn feedback_note_cancel_does_not_submit() {
|
||||
/*turn_id*/ None,
|
||||
AppEventSender::new(tx_raw),
|
||||
/*include_logs*/ false,
|
||||
FeedbackAudience::OpenAiEmployee,
|
||||
Some(FeedbackAudience::OpenAiEmployee),
|
||||
/*can_save*/ true,
|
||||
);
|
||||
view.textarea.insert_str("unfinished feedback");
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ use codex_feedback::FeedbackDiagnostics;
|
||||
use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::FeedbackCategory;
|
||||
@@ -182,21 +184,6 @@ pub(crate) fn feedback_selection_params(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the selection popup params shown when feedback is disabled.
|
||||
pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams {
|
||||
super::SelectionViewParams {
|
||||
title: Some("Sending feedback is disabled".to_string()),
|
||||
subtitle: Some("This action is disabled by configuration.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![super::SelectionItem {
|
||||
name: "Close".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..super::SelectionViewParams::picker()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_feedback_item(
|
||||
app_event_tx: AppEventSender,
|
||||
name: &str,
|
||||
@@ -215,16 +202,16 @@ fn make_feedback_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the upload consent popup params for a given feedback category.
|
||||
pub(crate) fn feedback_upload_consent_params(
|
||||
/// Choose optional attachments before saving or sending the report.
|
||||
pub(crate) fn feedback_consent_params(
|
||||
app_event_tx: AppEventSender,
|
||||
category: FeedbackCategory,
|
||||
rollout_path: Option<std::path::PathBuf>,
|
||||
auto_review_rollout_filename: Option<String>,
|
||||
include_windows_sandbox_log: bool,
|
||||
can_save: bool,
|
||||
feedback_diagnostics: &FeedbackDiagnostics,
|
||||
) -> super::SelectionViewParams {
|
||||
use super::popup_consts::standard_popup_hint_line;
|
||||
let yes_action: super::SelectionAction = Box::new({
|
||||
let tx = app_event_tx.clone();
|
||||
move |sender: &AppEventSender| {
|
||||
@@ -247,11 +234,18 @@ pub(crate) fn feedback_upload_consent_params(
|
||||
}
|
||||
});
|
||||
|
||||
// Build header listing files that would be sent if user consents.
|
||||
let disclosure = if can_save {
|
||||
"Saving a ZIP does not submit feedback to OpenAI.\nFiles may contain prompts, commands, and tool output:"
|
||||
} else {
|
||||
"Files may contain prompts, commands, and tool output:"
|
||||
};
|
||||
let mut header_lines: Vec<Box<dyn crate::render::renderable::Renderable>> = vec![
|
||||
Line::from("Upload logs?".bold()).into(),
|
||||
Line::from("Include logs and session history?".bold()).into(),
|
||||
Line::from("").into(),
|
||||
Line::from("The following files will be sent:".dim()).into(),
|
||||
Paragraph::new(disclosure)
|
||||
.dim()
|
||||
.wrap(Wrap { trim: false })
|
||||
.into(),
|
||||
Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(),
|
||||
Line::from(vec![" • ".into(), "client-logs.txt (if available)".into()]).into(),
|
||||
Line::from(vec![
|
||||
@@ -313,16 +307,14 @@ pub(crate) fn feedback_upload_consent_params(
|
||||
items: vec![
|
||||
super::SelectionItem {
|
||||
name: "Yes".to_string(),
|
||||
description: Some(
|
||||
"Share the current Codex session logs and diagnostics with the team for troubleshooting"
|
||||
.to_string(),
|
||||
),
|
||||
description: Some("Include session logs and diagnostics in the report".to_string()),
|
||||
actions: vec![yes_action],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
super::SelectionItem {
|
||||
name: "No".to_string(),
|
||||
description: Some("Keep only your note and report metadata".to_string()),
|
||||
actions: vec![no_action],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
@@ -398,12 +390,13 @@ mod tests {
|
||||
fn feedback_upload_consent_lists_doctor_report() {
|
||||
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let params = feedback_upload_consent_params(
|
||||
let params = feedback_consent_params(
|
||||
tx,
|
||||
FeedbackCategory::Bug,
|
||||
Some(std::path::PathBuf::from("rollout.jsonl")),
|
||||
Some("auto-review-rollout.jsonl".to_string()),
|
||||
/*include_windows_sandbox_log*/ false,
|
||||
/*can_save*/ true,
|
||||
&FeedbackDiagnostics::default(),
|
||||
);
|
||||
|
||||
@@ -416,12 +409,13 @@ mod tests {
|
||||
fn feedback_upload_consent_lists_windows_sandbox_log_when_included() {
|
||||
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let params = feedback_upload_consent_params(
|
||||
let params = feedback_consent_params(
|
||||
tx,
|
||||
FeedbackCategory::Bug,
|
||||
Some(std::path::PathBuf::from("rollout.jsonl")),
|
||||
Some("auto-review-rollout.jsonl".to_string()),
|
||||
/*include_windows_sandbox_log*/ true,
|
||||
/*can_save*/ true,
|
||||
&FeedbackDiagnostics::default(),
|
||||
);
|
||||
|
||||
|
||||
@@ -199,10 +199,9 @@ mod feedback_view;
|
||||
mod hooks_browser_view;
|
||||
pub(crate) use feedback_view::FeedbackAudience;
|
||||
pub(crate) use feedback_view::feedback_classification;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
pub(crate) use feedback_view::feedback_consent_params;
|
||||
pub(crate) use feedback_view::feedback_selection_params;
|
||||
pub(crate) use feedback_view::feedback_success_cell;
|
||||
pub(crate) use feedback_view::feedback_upload_consent_params;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
pub(crate) use status_line_setup::StatusLineItem;
|
||||
|
||||
+4
-4
@@ -2,12 +2,12 @@
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: "format!(\"{initial}\\n\\n{scrolled}\")"
|
||||
---
|
||||
▌ Your data may be used to improve our models and products
|
||||
▌ Feedback sent to OpenAI may be used to improve our models and products
|
||||
▌
|
||||
▌ A visible note.
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
▌ sensitive personal information.
|
||||
▌ You can also save feedback to a ZIP file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ A visible note.
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
+15
-15
@@ -5,8 +5,8 @@ expression: "rendered.join(\"\\n\\n\")"
|
||||
40x12 initial
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your data may be used to improve our
|
||||
▌ models and products
|
||||
▌ Feedback sent to OpenAI may be used to
|
||||
▌ improve our models and products
|
||||
▌ By submitting feedback, you agree that
|
||||
▌ OpenAI can use your feedback for
|
||||
▌ safety purposes and internal model
|
||||
@@ -14,27 +14,27 @@ expression: "rendered.join(\"\\n\\n\")"
|
||||
▌
|
||||
▌ A visible note.
|
||||
pgup/pgdn scroll disclosure
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save · enter send · esc cancel
|
||||
|
||||
40x12 scrolled
|
||||
▌ By submitting feedback, you agree that
|
||||
▌ OpenAI can use your feedback for
|
||||
▌ safety purposes and internal model
|
||||
▌ training, as explained in more detail
|
||||
▌ here. Please avoid sharing tented
|
||||
▌ information under NDA, or privileged,
|
||||
▌ HR-related, or sensitive personal
|
||||
▌ information.
|
||||
▌ You can also save feedback to a ZIP
|
||||
▌ file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ A visible note.
|
||||
pgup/pgdn scroll disclosure
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save · enter send · esc cancel
|
||||
|
||||
40x12 restored
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your data may be used to improve our
|
||||
▌ models and products
|
||||
▌ Feedback sent to OpenAI may be used to
|
||||
▌ improve our models and products
|
||||
▌ By submitting feedback, you agree that
|
||||
▌ OpenAI can use your feedback for
|
||||
▌ safety purposes and internal model
|
||||
@@ -42,34 +42,34 @@ Press enter to submit or esc to cancel
|
||||
▌
|
||||
▌ A visible note.
|
||||
pgup/pgdn scroll disclosure
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save · enter send · esc cancel
|
||||
|
||||
80x8 initial
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your data may be used to improve our models and products
|
||||
▌ Feedback sent to OpenAI may be used to improve our models and products
|
||||
▌ By submitting feedback, you agree that OpenAI can use your feedback for safety
|
||||
▌
|
||||
▌ A visible note.
|
||||
pgup/pgdn scroll disclosure
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
80x8 scrolled
|
||||
▌ By submitting feedback, you agree that OpenAI can use your feedback for safety
|
||||
▌ purposes and internal model training, as explained in more detail here. Please
|
||||
▌ avoid sharing tented information under NDA, or privileged, HR-related, or
|
||||
▌ sensitive personal information.
|
||||
▌ You can also save feedback to a ZIP file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ A visible note.
|
||||
pgup/pgdn scroll disclosure
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
80x8 restored
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your data may be used to improve our models and products
|
||||
▌ Feedback sent to OpenAI may be used to improve our models and products
|
||||
▌ By submitting feedback, you agree that OpenAI can use your feedback for safety
|
||||
▌
|
||||
▌ A visible note.
|
||||
pgup/pgdn scroll disclosure
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
+25
-10
@@ -5,40 +5,44 @@ expression: "rendered.join(\"\\n\\n\")"
|
||||
external (80 columns)
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your feedback can be used to improve ChatGPT. Learn more.
|
||||
▌ Feedback sent to OpenAI can be used to improve ChatGPT. Learn more.
|
||||
▌ You can also save feedback to a ZIP file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
external (40 columns)
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your feedback can be used to improve
|
||||
▌ ChatGPT. Learn more.
|
||||
▌ Feedback sent to OpenAI can be used to
|
||||
▌ improve ChatGPT. Learn more.
|
||||
▌ You can also save feedback to a ZIP
|
||||
▌ file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ (optional) Write a short description t
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save · enter send · esc cancel
|
||||
|
||||
employee (80 columns)
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your data may be used to improve our models and products
|
||||
▌ Feedback sent to OpenAI may be used to improve our models and products
|
||||
▌ By submitting feedback, you agree that OpenAI can use your feedback for safety
|
||||
▌ purposes and internal model training, as explained in more detail here. Please
|
||||
▌ avoid sharing tented information under NDA, or privileged, HR-related, or
|
||||
▌ sensitive personal information.
|
||||
▌ You can also save feedback to a ZIP file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
employee (40 columns)
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your data may be used to improve our
|
||||
▌ models and products
|
||||
▌ Feedback sent to OpenAI may be used to
|
||||
▌ improve our models and products
|
||||
▌ By submitting feedback, you agree that
|
||||
▌ OpenAI can use your feedback for
|
||||
▌ safety purposes and internal model
|
||||
@@ -47,7 +51,18 @@ employee (40 columns)
|
||||
▌ information under NDA, or privileged,
|
||||
▌ HR-related, or sensitive personal
|
||||
▌ information.
|
||||
▌ You can also save feedback to a ZIP
|
||||
▌ file, which is not sent to OpenAI.
|
||||
▌
|
||||
▌ (optional) Write a short description t
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save · enter send · esc cancel
|
||||
|
||||
remote server (80 columns)
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Feedback sent to OpenAI can be used to improve ChatGPT. Learn more.
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
enter send to OpenAI · esc cancel
|
||||
|
||||
+5
-2
@@ -4,9 +4,12 @@ expression: "render(&view, 60)"
|
||||
---
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your feedback can be used to improve ChatGPT. Learn more.
|
||||
▌ Feedback sent to OpenAI can be used to improve ChatGPT.
|
||||
▌ Learn more.
|
||||
▌ You can also save feedback to a ZIP file, which is not
|
||||
▌ sent to OpenAI.
|
||||
▌
|
||||
▌ foo
|
||||
▌ bar
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_note_view_tests.rs
|
||||
expression: "render(&view, 60)"
|
||||
---
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Feedback uploads are disabled.
|
||||
▌ You can save feedback to a ZIP file, which is not sent to
|
||||
▌ OpenAI.
|
||||
▌
|
||||
▌ Review before sharing
|
||||
|
||||
⌃s save ZIP · esc cancel
|
||||
+5
-2
@@ -4,8 +4,11 @@ expression: rendered
|
||||
---
|
||||
▌ Tell us more (bug)
|
||||
▌
|
||||
▌ Your feedback can be used to improve ChatGPT. Learn more.
|
||||
▌ Feedback sent to OpenAI can be used to improve ChatGPT.
|
||||
▌ Learn more.
|
||||
▌ You can also save feedback to a ZIP file, which is not
|
||||
▌ sent to OpenAI.
|
||||
▌
|
||||
▌ (optional) Write a short description to help us further
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
+5
-2
@@ -4,8 +4,11 @@ expression: rendered
|
||||
---
|
||||
▌ Tell us more (safety check)
|
||||
▌
|
||||
▌ Your feedback can be used to improve ChatGPT. Learn more.
|
||||
▌ Feedback sent to OpenAI can be used to improve ChatGPT.
|
||||
▌ Learn more.
|
||||
▌ You can also save feedback to a ZIP file, which is not
|
||||
▌ sent to OpenAI.
|
||||
▌
|
||||
▌ (optional) Share what was refused and why it should have b
|
||||
|
||||
Press enter to submit or esc to cancel
|
||||
⌃s save ZIP · enter send to OpenAI · esc cancel
|
||||
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
assertion_line: 728
|
||||
expression: rendered
|
||||
---
|
||||
Upload logs?
|
||||
Include logs and session history?
|
||||
|
||||
The following files will be sent:
|
||||
Saving a ZIP does not submit feedback to OpenAI.
|
||||
Files may contain prompts, commands, and tool output:
|
||||
• codex-logs.log
|
||||
• client-logs.txt (if available)
|
||||
• codex-doctor-report.json
|
||||
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/feedback_view.rs
|
||||
assertion_line: 746
|
||||
expression: rendered
|
||||
---
|
||||
Upload logs?
|
||||
Include logs and session history?
|
||||
|
||||
The following files will be sent:
|
||||
Saving a ZIP does not submit feedback to OpenAI.
|
||||
Files may contain prompts, commands, and tool output:
|
||||
• codex-logs.log
|
||||
• client-logs.txt (if available)
|
||||
• codex-doctor-report.json
|
||||
|
||||
@@ -1001,18 +1001,36 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback(&mut self, mode: crate::ThreadParamsMode) {
|
||||
use crate::ThreadParamsMode;
|
||||
let unavailable = match mode {
|
||||
ThreadParamsMode::Cloud => "Feedback is unavailable in Cloud threads.",
|
||||
ThreadParamsMode::Remote if !self.config.feedback_enabled => {
|
||||
"Feedback uploads are disabled, and saving a ZIP requires a local app server."
|
||||
}
|
||||
_ => {
|
||||
return self.show_selection_view(crate::bottom_pane::feedback_selection_params(
|
||||
self.app_event_tx.clone(),
|
||||
));
|
||||
}
|
||||
};
|
||||
self.add_info_message(unavailable.to_string(), /*hint*/ None);
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback_note(
|
||||
&mut self,
|
||||
category: crate::app_event::FeedbackCategory,
|
||||
include_logs: bool,
|
||||
feedback_audience: crate::bottom_pane::FeedbackAudience,
|
||||
can_save: bool,
|
||||
) {
|
||||
let view = crate::bottom_pane::FeedbackNoteView::new(
|
||||
category,
|
||||
self.turn_lifecycle.last_turn_id.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
include_logs,
|
||||
feedback_audience,
|
||||
self.config.feedback_enabled.then_some(feedback_audience),
|
||||
can_save,
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
self.request_redraw();
|
||||
@@ -1054,7 +1072,11 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) {
|
||||
pub(crate) fn open_feedback_consent(
|
||||
&mut self,
|
||||
category: crate::app_event::FeedbackCategory,
|
||||
can_save: bool,
|
||||
) {
|
||||
let snapshot = self.feedback.snapshot(self.thread_id);
|
||||
#[cfg(target_os = "windows")]
|
||||
let include_windows_sandbox_log =
|
||||
@@ -1064,13 +1086,14 @@ impl ChatWidget {
|
||||
.is_file();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let include_windows_sandbox_log = false;
|
||||
let params = crate::bottom_pane::feedback_upload_consent_params(
|
||||
let params = crate::bottom_pane::feedback_consent_params(
|
||||
self.app_event_tx.clone(),
|
||||
category,
|
||||
self.current_rollout_path.clone(),
|
||||
self.thread_id
|
||||
.map(|thread_id| format!("auto-review-rollout-{thread_id}.jsonl")),
|
||||
include_windows_sandbox_log,
|
||||
can_save,
|
||||
snapshot.feedback_diagnostics(),
|
||||
);
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
|
||||
@@ -239,17 +239,7 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
SlashCommand::Feedback => {
|
||||
if !self.config.feedback_enabled {
|
||||
let params = crate::bottom_pane::feedback_disabled_params();
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
// Step 1: pick a category (UI built in feedback_view)
|
||||
let params =
|
||||
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
self.request_redraw();
|
||||
self.app_event_tx.send(AppEvent::OpenFeedback);
|
||||
}
|
||||
SlashCommand::New => {
|
||||
self.show_session_checkout_picker(ManagedWorktreeMode::New, /*name*/ None);
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))"
|
||||
---
|
||||
› /feedback send logs to maintainers or save a ZIP
|
||||
|
||||
› /feedback
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
+6
-6
@@ -2,10 +2,11 @@
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
Include logs and session history?
|
||||
› As
|
||||
The following files will be sent:
|
||||
GP • codex-logs.log
|
||||
Saving a ZIP does not submit feedback to OpenAI.
|
||||
GP Files may contain prompts, commands, and tool output:
|
||||
• codex-logs.log
|
||||
• client-logs.txt (if available)
|
||||
• codex-doctor-report.json
|
||||
• codex-apps-tools-cache.json (if available)
|
||||
@@ -14,8 +15,7 @@ expression: popup
|
||||
• codex-connectivity-diagnostics.txt
|
||||
|
||||
|
||||
› 1. Yes Share the current Codex session logs and diagnostics with the
|
||||
team for troubleshooting
|
||||
2. No
|
||||
› 1. Yes Include session logs and diagnostics in the report
|
||||
2. No Keep only your note and report metadata
|
||||
|
||||
enter select · esc back
|
||||
|
||||
+6
-6
@@ -2,10 +2,11 @@
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
Include logs and session history?
|
||||
› As
|
||||
The following files will be sent:
|
||||
GP • codex-logs.log
|
||||
Saving a ZIP does not submit feedback to OpenAI.
|
||||
GP Files may contain prompts, commands, and tool output:
|
||||
• codex-logs.log
|
||||
• client-logs.txt (if available)
|
||||
• codex-doctor-report.json
|
||||
• codex-apps-tools-cache.json (if available)
|
||||
@@ -19,8 +20,7 @@ expression: popup
|
||||
- HTTPS_PROXY = hello
|
||||
|
||||
|
||||
› 1. Yes Share the current Codex session logs and diagnostics with the
|
||||
team for troubleshooting
|
||||
2. No
|
||||
› 1. Yes Include session logs and diagnostics in the report
|
||||
2. No Keep only your note and report metadata
|
||||
|
||||
enter select · esc back
|
||||
|
||||
@@ -1048,6 +1048,7 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() {
|
||||
crate::app_event::FeedbackCategory::Bug,
|
||||
/*include_logs*/ false,
|
||||
crate::bottom_pane::FeedbackAudience::External,
|
||||
/*can_save*/ true,
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
@@ -1058,6 +1059,7 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() {
|
||||
reason: None,
|
||||
turn_id: Some(turn_id),
|
||||
include_logs: false,
|
||||
destination: crate::app_event::FeedbackDestination::OpenAi,
|
||||
}) if turn_id == "turn-1"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4318,25 +4318,54 @@ async fn auto_model_advertising_advanced_effort_opens_reasoning_picker() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
// Open the feedback category selection popup via slash command.
|
||||
chat.dispatch_command(SlashCommand::Feedback);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/feedback".into(), Vec::new(), Vec::new());
|
||||
assert_chatwidget_snapshot!(
|
||||
"feedback_command_suggestion",
|
||||
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
|
||||
);
|
||||
chat.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::OpenFeedback))
|
||||
);
|
||||
chat.open_feedback(crate::ThreadParamsMode::Embedded);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("feedback_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_stops_before_collecting_a_note_when_no_destination_is_available() {
|
||||
use crate::ThreadParamsMode;
|
||||
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.open_feedback(ThreadParamsMode::Cloud);
|
||||
chat.config.feedback_enabled = false;
|
||||
chat.open_feedback(ThreadParamsMode::Remote);
|
||||
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
let messages = lines_to_single_string(&drain_insert_history(&mut rx).concat());
|
||||
insta::assert_snapshot!(messages.trim(), @"
|
||||
• Feedback is unavailable in Cloud threads.
|
||||
|
||||
• Feedback uploads are disabled, and saving a ZIP requires a local app server.
|
||||
");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_upload_consent_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params(
|
||||
chat.show_selection_view(crate::bottom_pane::feedback_consent_params(
|
||||
chat.app_event_tx.clone(),
|
||||
crate::app_event::FeedbackCategory::Bug,
|
||||
chat.current_rollout_path.clone(),
|
||||
Some("auto-review-rollout-thread-1.jsonl".to_string()),
|
||||
/*include_windows_sandbox_log*/ true,
|
||||
/*can_save*/ true,
|
||||
&codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic {
|
||||
headline: "Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
@@ -4352,12 +4381,13 @@ async fn feedback_upload_consent_popup_snapshot() {
|
||||
async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_filename() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params(
|
||||
chat.show_selection_view(crate::bottom_pane::feedback_consent_params(
|
||||
chat.app_event_tx.clone(),
|
||||
crate::app_event::FeedbackCategory::GoodResult,
|
||||
chat.current_rollout_path.clone(),
|
||||
Some("auto-review-rollout-thread-1.jsonl".to_string()),
|
||||
/*include_windows_sandbox_log*/ false,
|
||||
/*can_save*/ true,
|
||||
&codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic {
|
||||
headline: "Proxy environment variables are set and may affect connectivity."
|
||||
.to_string(),
|
||||
|
||||
@@ -93,7 +93,7 @@ impl SlashCommand {
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::Config => "configure Codex preferences",
|
||||
SlashCommand::Feedback => "send logs to maintainers",
|
||||
SlashCommand::Feedback => "send logs to maintainers or save a ZIP",
|
||||
SlashCommand::New => "start a new chat during a conversation",
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
|
||||
Reference in New Issue
Block a user