mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Show unread sessions and acknowledge viewed transcripts in the picker (#58264)
## What changed - Show a blue unread dot in both session picker layouts using backend read states. - Acknowledge the transcript's observed revision after rendering it in a focused terminal, then update the unread indicator from the response. - Key cached transcripts by backend and thread, reload them when unread revisions differ, and ignore canceled transcript results. - Include turn errors in transcripts. ## Testing Extend picker tests to cover unread indicators, selection colors, acknowledgments after rendering, and turn error display. GitOrigin-RevId: edad152ed3f7086dfd3b2e8245d62febc5c9322b
This commit is contained in:
@@ -16,6 +16,7 @@ mod provider_selection_tests;
|
||||
mod read_state;
|
||||
mod realtime;
|
||||
mod rollout_history;
|
||||
pub(crate) use read_state::unread_revision;
|
||||
mod startup_launch;
|
||||
mod thread_list;
|
||||
mod web_search;
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::app_server_session::unread_revision;
|
||||
use crate::backend_id::BackendId;
|
||||
use crate::clipboard_paste::normalize_pasted_search_query;
|
||||
use crate::clock_format::ClockFormat;
|
||||
use crate::color::blend;
|
||||
@@ -197,6 +199,11 @@ struct PageLoadRequest {
|
||||
}
|
||||
|
||||
enum PickerLoadRequest {
|
||||
Acknowledge {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
thread_id: ThreadId,
|
||||
revision: String,
|
||||
},
|
||||
Page(PageLoadRequest),
|
||||
Preview {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
@@ -338,6 +345,11 @@ struct PickerBackground {
|
||||
}
|
||||
|
||||
enum BackgroundEvent {
|
||||
Acknowledged {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
thread_id: ThreadId,
|
||||
revision: Option<String>,
|
||||
},
|
||||
PartialPage {
|
||||
listing_token: usize,
|
||||
page: PickerPage,
|
||||
@@ -357,8 +369,9 @@ enum BackgroundEvent {
|
||||
preview: std::io::Result<Vec<TranscriptPreviewLine>>,
|
||||
},
|
||||
Transcript {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
thread_id: ThreadId,
|
||||
transcript: std::io::Result<TranscriptCells>,
|
||||
transcript: std::io::Result<LoadedTranscript>,
|
||||
},
|
||||
Archive {
|
||||
thread_id: ThreadId,
|
||||
@@ -379,6 +392,7 @@ enum PageCursor {
|
||||
|
||||
struct PickerPage {
|
||||
rows: Vec<Row>,
|
||||
read_states: HashMap<String, codex_app_server_protocol::ThreadReadState>,
|
||||
history_modes: HashMap<ThreadId, ThreadHistoryMode>,
|
||||
next_cursor: Option<PageCursor>,
|
||||
num_scanned_files: usize,
|
||||
@@ -836,6 +850,22 @@ fn spawn_app_server_page_loader(
|
||||
page,
|
||||
});
|
||||
}
|
||||
PickerLoadRequest::Acknowledge {
|
||||
thread_id,
|
||||
revision,
|
||||
..
|
||||
} => match app_server.acknowledge_read(thread_id, revision).await {
|
||||
Ok(state) => {
|
||||
let _ = bg_tx.send(BackgroundEvent::Acknowledged {
|
||||
backend_id,
|
||||
thread_id,
|
||||
revision: unread_revision(Some(state)),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%thread_id, %error, "Could not acknowledge viewed transcript")
|
||||
}
|
||||
},
|
||||
PickerLoadRequest::Preview { thread_id, .. } => {
|
||||
let preview = tokio::select! {
|
||||
preview = load_transcript_preview(&mut app_server, thread_id, config.as_ref()) => preview,
|
||||
@@ -856,8 +886,10 @@ fn spawn_app_server_page_loader(
|
||||
config.as_ref(),
|
||||
) => {
|
||||
let _ = bg_tx.send(BackgroundEvent::Transcript {
|
||||
thread_id,
|
||||
transcript,
|
||||
backend_id, thread_id,
|
||||
transcript: transcript.map(|cells| LoadedTranscript {
|
||||
cells, unread_revision: app_server.unread_revisions.remove(&thread_id),
|
||||
}),
|
||||
});
|
||||
}
|
||||
_ = cancellation => {}
|
||||
@@ -991,8 +1023,10 @@ struct PickerState {
|
||||
archive_state: archive::ArchiveState,
|
||||
expanded_thread_id: Option<ThreadId>,
|
||||
transcript_previews: HashMap<ThreadId, TranscriptPreviewState>,
|
||||
transcript_cells: HashMap<ThreadId, SessionTranscriptState>,
|
||||
pending_transcript_open: Option<ThreadId>,
|
||||
transcript_cells: HashMap<(BackendId, ThreadId), SessionTranscriptState>,
|
||||
pending_transcript_open: Option<(BackendId, ThreadId)>,
|
||||
unread: HashMap<(BackendId, ThreadId), String>,
|
||||
overlay_receipt: Option<PickerLoadRequest>,
|
||||
pending_transcript_cancellation: Option<oneshot::Sender<()>>,
|
||||
transcript_loading_frame_shown: bool,
|
||||
overlay: Option<Overlay>,
|
||||
@@ -1014,9 +1048,14 @@ enum TranscriptPreviewState {
|
||||
Failed,
|
||||
}
|
||||
|
||||
struct LoadedTranscript {
|
||||
cells: TranscriptCells,
|
||||
unread_revision: Option<String>,
|
||||
}
|
||||
|
||||
enum SessionTranscriptState {
|
||||
Loading,
|
||||
Loaded(TranscriptCells),
|
||||
Loaded(LoadedTranscript),
|
||||
Failed,
|
||||
}
|
||||
|
||||
@@ -1059,6 +1098,7 @@ async fn load_app_server_page(
|
||||
|
||||
Ok(PickerPage {
|
||||
rows,
|
||||
read_states: response.read_states.unwrap_or_default(),
|
||||
history_modes,
|
||||
next_cursor: response.next_cursor.map(PageCursor::AppServer),
|
||||
num_scanned_files,
|
||||
@@ -1199,6 +1239,8 @@ impl PickerState {
|
||||
transcript_previews: HashMap::new(),
|
||||
transcript_cells: HashMap::new(),
|
||||
pending_transcript_open: None,
|
||||
unread: HashMap::new(),
|
||||
overlay_receipt: None,
|
||||
pending_transcript_cancellation: None,
|
||||
transcript_loading_frame_shown: false,
|
||||
overlay: None,
|
||||
@@ -1261,15 +1303,16 @@ impl PickerState {
|
||||
if !self.transcript_loading_frame_shown {
|
||||
return;
|
||||
}
|
||||
let Some(thread_id) = self.pending_transcript_open else {
|
||||
let Some((backend_id, thread_id)) = self.pending_transcript_open else {
|
||||
return;
|
||||
};
|
||||
let Some(SessionTranscriptState::Loaded(cells)) = self.transcript_cells.get(&thread_id)
|
||||
let Some(SessionTranscriptState::Loaded(cells)) =
|
||||
self.transcript_cells.get(&(backend_id, thread_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut overlay = Overlay::new_transcript(
|
||||
cells.clone(),
|
||||
cells.cells.clone(),
|
||||
self.keymap.pager.clone(),
|
||||
self.copy_on_select,
|
||||
self.mouse_scroll_speed,
|
||||
@@ -1279,23 +1322,36 @@ impl PickerState {
|
||||
view.set_keymap_bindings(&self.keymap);
|
||||
}
|
||||
self.chord_matcher.cancel();
|
||||
self.overlay_receipt =
|
||||
cells
|
||||
.unread_revision
|
||||
.clone()
|
||||
.map(|revision| PickerLoadRequest::Acknowledge {
|
||||
backend_id,
|
||||
thread_id,
|
||||
revision,
|
||||
});
|
||||
self.overlay = Some(overlay);
|
||||
self.pending_transcript_open = None;
|
||||
self.transcript_loading_frame_shown = false;
|
||||
self.request_frame();
|
||||
}
|
||||
|
||||
fn begin_transcript_loading(&mut self, thread_id: ThreadId) {
|
||||
self.pending_transcript_open = Some(thread_id);
|
||||
self.transcript_loading_frame_shown = false;
|
||||
self.request_frame();
|
||||
}
|
||||
|
||||
fn handle_overlay_event(&mut self, tui: &mut Tui, event: TuiEvent) -> Result<()> {
|
||||
let Some(overlay) = &mut self.overlay else {
|
||||
return Ok(());
|
||||
};
|
||||
let draw = matches!(
|
||||
event,
|
||||
TuiEvent::Draw | TuiEvent::FocusGained | TuiEvent::Resume | TuiEvent::Resize(_)
|
||||
);
|
||||
overlay.handle_event(tui, event)?;
|
||||
if draw
|
||||
&& tui.is_terminal_focused()
|
||||
&& let Some(receipt) = self.overlay_receipt.take()
|
||||
{
|
||||
(self.picker_loader)(receipt);
|
||||
}
|
||||
if overlay.is_done() {
|
||||
self.overlay = None;
|
||||
self.request_frame();
|
||||
@@ -1303,6 +1359,19 @@ impl PickerState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_unread(&mut self, key: (BackendId, ThreadId), revision: Option<String>) {
|
||||
if let Some(revision) = revision {
|
||||
self.unread.insert(key, revision);
|
||||
} else {
|
||||
self.unread.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn unread_revision(&self, row: &Row) -> Option<&String> {
|
||||
self.unread
|
||||
.get(&(row.backend_id.unwrap_or(self.backend_id), row.thread_id?))
|
||||
}
|
||||
|
||||
fn open_selected_transcript(&mut self) {
|
||||
let Some(row) = self.filtered_rows.get(self.selected) else {
|
||||
return;
|
||||
@@ -1313,17 +1382,17 @@ impl PickerState {
|
||||
return;
|
||||
};
|
||||
let backend_id = row.backend_id.unwrap_or(self.backend_id);
|
||||
match self.transcript_cells.get(&thread_id) {
|
||||
Some(SessionTranscriptState::Loaded(_)) => {
|
||||
self.begin_transcript_loading(thread_id);
|
||||
}
|
||||
Some(SessionTranscriptState::Loading) => {
|
||||
self.begin_transcript_loading(thread_id);
|
||||
}
|
||||
Some(SessionTranscriptState::Failed) | None => {
|
||||
let key = (backend_id, thread_id);
|
||||
self.pending_transcript_open = Some(key);
|
||||
self.transcript_loading_frame_shown = false;
|
||||
self.request_frame();
|
||||
match self.transcript_cells.get(&key) {
|
||||
Some(SessionTranscriptState::Loaded(transcript))
|
||||
if self.unread.get(&key) == transcript.unread_revision.as_ref() => {}
|
||||
Some(SessionTranscriptState::Loading) => {}
|
||||
_ => {
|
||||
self.transcript_cells
|
||||
.insert(thread_id, SessionTranscriptState::Loading);
|
||||
self.begin_transcript_loading(thread_id);
|
||||
.insert(key, SessionTranscriptState::Loading);
|
||||
let (cancellation_tx, cancellation) = oneshot::channel();
|
||||
self.pending_transcript_cancellation = Some(cancellation_tx);
|
||||
(self.picker_loader)(PickerLoadRequest::Transcript {
|
||||
@@ -1343,13 +1412,13 @@ impl PickerState {
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit),
|
||||
key if self.keymap.list.cancel.is_pressed(key) => {
|
||||
if let Some(thread_id) = self.pending_transcript_open.take()
|
||||
if let Some(key) = self.pending_transcript_open.take()
|
||||
&& matches!(
|
||||
self.transcript_cells.get(&thread_id),
|
||||
self.transcript_cells.get(&key),
|
||||
Some(SessionTranscriptState::Loading)
|
||||
)
|
||||
{
|
||||
self.transcript_cells.remove(&thread_id);
|
||||
self.transcript_cells.remove(&key);
|
||||
}
|
||||
if let Some(cancellation) = self.pending_transcript_cancellation.take() {
|
||||
let _ = cancellation.send(());
|
||||
@@ -1580,6 +1649,7 @@ impl PickerState {
|
||||
fn start_initial_load(&mut self) {
|
||||
self.relative_time_reference = Some(Utc::now());
|
||||
self.reset_pagination();
|
||||
self.unread.clear();
|
||||
self.all_rows.clear();
|
||||
self.filtered_rows.clear();
|
||||
self.seen_rows.clear();
|
||||
@@ -1712,33 +1782,47 @@ impl PickerState {
|
||||
);
|
||||
self.request_frame();
|
||||
}
|
||||
BackgroundEvent::Acknowledged {
|
||||
backend_id,
|
||||
thread_id,
|
||||
revision,
|
||||
} => {
|
||||
self.update_unread((backend_id, thread_id), revision);
|
||||
self.request_frame();
|
||||
}
|
||||
BackgroundEvent::Transcript {
|
||||
backend_id,
|
||||
thread_id,
|
||||
transcript,
|
||||
} => match transcript {
|
||||
Ok(cells) => {
|
||||
let should_open = self.pending_transcript_open == Some(thread_id);
|
||||
self.transcript_cells
|
||||
.insert(thread_id, SessionTranscriptState::Loaded(cells.clone()));
|
||||
if should_open {
|
||||
self.pending_transcript_cancellation = None;
|
||||
} => {
|
||||
let key = (backend_id, thread_id);
|
||||
if self.pending_transcript_open != Some(key)
|
||||
|| !matches!(
|
||||
self.transcript_cells.get(&key),
|
||||
Some(SessionTranscriptState::Loading)
|
||||
)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
self.pending_transcript_cancellation = None;
|
||||
match transcript {
|
||||
Ok(cells) => {
|
||||
self.update_unread(key, cells.unread_revision.clone());
|
||||
self.transcript_cells
|
||||
.insert(key, SessionTranscriptState::Loaded(cells));
|
||||
self.open_pending_transcript_if_ready();
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
Err(_) => {
|
||||
self.transcript_cells
|
||||
.insert(thread_id, SessionTranscriptState::Failed);
|
||||
if self.pending_transcript_open == Some(thread_id) {
|
||||
Err(_) => {
|
||||
self.transcript_cells
|
||||
.insert(key, SessionTranscriptState::Failed);
|
||||
self.chord_matcher.cancel();
|
||||
self.pending_transcript_cancellation = None;
|
||||
self.pending_transcript_open = None;
|
||||
self.transcript_loading_frame_shown = false;
|
||||
self.inline_error = Some("Could not load transcript preview".to_string());
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
},
|
||||
self.request_frame();
|
||||
}
|
||||
BackgroundEvent::Archive { thread_id, result } => {
|
||||
self.handle_archive_result(thread_id, result);
|
||||
}
|
||||
@@ -1764,6 +1848,7 @@ impl PickerState {
|
||||
.map(|target| target.saturating_sub(self.selected));
|
||||
let PickerPage {
|
||||
rows,
|
||||
mut read_states,
|
||||
history_modes,
|
||||
next_cursor,
|
||||
num_scanned_files,
|
||||
@@ -1774,6 +1859,10 @@ impl PickerState {
|
||||
for mut row in rows {
|
||||
if let Some(thread_id) = row.thread_id {
|
||||
row.history_mode = history_modes.get(&thread_id).copied();
|
||||
self.update_unread(
|
||||
(row.backend_id.unwrap_or(self.backend_id), thread_id),
|
||||
unread_revision(read_states.remove(&thread_id.to_string())),
|
||||
);
|
||||
}
|
||||
if let Some(cwd) = row.cwd.as_ref()
|
||||
&& !self.local_cwd_matches.contains_key(cwd)
|
||||
@@ -3077,11 +3166,6 @@ fn render_comfortable_session_lines(
|
||||
is_zebra: bool,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
let marker = selection_marker(is_selected, is_expanded);
|
||||
let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize);
|
||||
let title = session_title_span(title, row.thread_id, state.use_theme_colors, is_selected);
|
||||
let title_line = Line::from(vec![marker, title]);
|
||||
let mut lines = vec![title_line];
|
||||
let row_style = if is_selected {
|
||||
dense_selected_style()
|
||||
} else if is_zebra {
|
||||
@@ -3089,7 +3173,20 @@ fn render_comfortable_session_lines(
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines = apply_session_row_background(lines, row_style, width);
|
||||
let marker = selection_marker(is_selected, is_expanded);
|
||||
let unread = state.unread_revision(row).is_some();
|
||||
let title = truncate_text(
|
||||
row.display_preview(),
|
||||
(width as usize).saturating_sub(marker.width() + 2 * usize::from(unread)),
|
||||
);
|
||||
let title = session_title_span(title, row.thread_id, state.use_theme_colors, is_selected);
|
||||
let mut lines = vec![session_summary_line(
|
||||
vec![marker],
|
||||
title,
|
||||
unread,
|
||||
row_style,
|
||||
width,
|
||||
)];
|
||||
if is_expanded {
|
||||
lines.extend(render_transcript_preview_lines(row, state, width));
|
||||
return lines;
|
||||
@@ -3181,6 +3278,7 @@ fn render_dense_session_lines(
|
||||
marker,
|
||||
date: &date,
|
||||
title: row.display_preview(),
|
||||
unread: state.unread_revision(row).is_some(),
|
||||
thread_id: row.thread_id,
|
||||
use_theme_colors: state.use_theme_colors,
|
||||
is_selected,
|
||||
@@ -3194,6 +3292,7 @@ fn render_dense_session_lines(
|
||||
}
|
||||
|
||||
struct DenseSummaryInput<'a> {
|
||||
unread: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
use_theme_colors: bool,
|
||||
marker: Span<'static>,
|
||||
@@ -3205,11 +3304,23 @@ struct DenseSummaryInput<'a> {
|
||||
}
|
||||
|
||||
fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> {
|
||||
let row_style = if input.is_selected {
|
||||
dense_selected_style()
|
||||
} else if input.is_zebra {
|
||||
dense_zebra_style()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let marker_width = input.marker.width();
|
||||
let available = (input.width as usize).saturating_sub(marker_width);
|
||||
let columns = dense_columns(available);
|
||||
let title = session_title_span(
|
||||
dense_column_text(input.title, columns.title_width),
|
||||
dense_column_text(
|
||||
input.title,
|
||||
columns
|
||||
.title_width
|
||||
.saturating_sub(2 * usize::from(input.unread)),
|
||||
),
|
||||
input.thread_id,
|
||||
input.use_theme_colors,
|
||||
input.is_selected,
|
||||
@@ -3218,17 +3329,32 @@ fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> {
|
||||
let spans = vec![
|
||||
input.marker,
|
||||
dense_column_text(input.date, columns.date_width).set_style(secondary_text_style()),
|
||||
title,
|
||||
];
|
||||
let line = Line::from(spans);
|
||||
let row_style = if input.is_selected {
|
||||
dense_selected_style()
|
||||
} else if input.is_zebra {
|
||||
dense_zebra_style()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
apply_line_background(line, row_style, input.width)
|
||||
session_summary_line(spans, title, input.unread, row_style, input.width)
|
||||
}
|
||||
|
||||
/// Paint the title and metadata first so contrast adjustment preserves the fixed blue dot.
|
||||
fn session_summary_line(
|
||||
mut prefix: Vec<Span<'static>>,
|
||||
title: Span<'static>,
|
||||
unread: bool,
|
||||
row_style: Style,
|
||||
width: u16,
|
||||
) -> Line<'static> {
|
||||
let title_index = prefix.len();
|
||||
prefix.push(title);
|
||||
let mut line = apply_line_background(
|
||||
prefix.into(),
|
||||
row_style,
|
||||
width.saturating_sub(2 * u16::from(unread)),
|
||||
);
|
||||
if unread {
|
||||
line.spans.splice(
|
||||
title_index..title_index,
|
||||
[crate::style::task_marker(unread, row_style), " ".into()],
|
||||
);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
struct DenseColumns {
|
||||
@@ -3844,6 +3970,7 @@ mod tests {
|
||||
) -> PickerPage {
|
||||
PickerPage {
|
||||
rows,
|
||||
read_states: HashMap::new(),
|
||||
history_modes: HashMap::new(),
|
||||
next_cursor: next_cursor.map(|cursor| PageCursor::AppServer(cursor.to_string())),
|
||||
num_scanned_files,
|
||||
@@ -4731,6 +4858,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
|
||||
let unread_id = ThreadId::from_u128(/*value*/ 2);
|
||||
let mut rows = [
|
||||
Row {
|
||||
backend_id: None,
|
||||
@@ -4749,7 +4877,7 @@ mod tests {
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/b.jsonl")),
|
||||
preview: String::from("Investigate lazy pagination cap"),
|
||||
thread_id: None,
|
||||
thread_id: Some(unread_id),
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::hours(1)),
|
||||
updated_at: Some(now - Duration::minutes(35)),
|
||||
@@ -4775,12 +4903,20 @@ mod tests {
|
||||
for row in &mut rows[1..] {
|
||||
row.backend_id = Some(local_backend);
|
||||
}
|
||||
state.ingest_page(page(
|
||||
let mut local_page = page(
|
||||
rows[1..].to_vec(),
|
||||
/*next_cursor*/ None,
|
||||
/*num_scanned_files*/ 2,
|
||||
/*reached_scan_cap*/ false,
|
||||
));
|
||||
);
|
||||
local_page.read_states.insert(
|
||||
unread_id.to_string(),
|
||||
codex_app_server_protocol::ThreadReadState {
|
||||
first_unread: Some(codex_app_server_protocol::ThreadUnreadPosition::ThreadStart),
|
||||
revision: "revision".into(),
|
||||
},
|
||||
);
|
||||
state.ingest_page(local_page);
|
||||
state.selected = 0;
|
||||
state.ingest_page(page(
|
||||
vec![rows[0].clone()],
|
||||
@@ -5032,7 +5168,7 @@ mod tests {
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.pending_transcript_open = Some(ThreadId::new());
|
||||
state.pending_transcript_open = Some((state.backend_id, ThreadId::new()));
|
||||
|
||||
let rendered = footer_lines_text(&state, /*width*/ 80);
|
||||
|
||||
@@ -5249,9 +5385,10 @@ mod tests {
|
||||
|
||||
assert_eq!(state.density, SessionListDensity::Comfortable);
|
||||
assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]);
|
||||
assert_eq!(state.pending_transcript_open, Some(thread_id));
|
||||
let key = (state.backend_id, thread_id);
|
||||
assert_eq!(state.pending_transcript_open, Some(key));
|
||||
assert!(matches!(
|
||||
state.transcript_cells.get(&thread_id),
|
||||
state.transcript_cells.get(&key),
|
||||
Some(SessionTranscriptState::Loading)
|
||||
));
|
||||
}
|
||||
@@ -5295,7 +5432,7 @@ mod tests {
|
||||
git_branch: None,
|
||||
},
|
||||
];
|
||||
state.pending_transcript_open = Some(thread_id);
|
||||
state.pending_transcript_open = Some((state.backend_id, thread_id));
|
||||
|
||||
let selection = state
|
||||
.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
|
||||
@@ -5345,7 +5482,8 @@ mod tests {
|
||||
.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state.pending_transcript_open, Some(thread_id));
|
||||
let key = (state.backend_id, thread_id);
|
||||
assert_eq!(state.pending_transcript_open, Some(key));
|
||||
|
||||
state
|
||||
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
@@ -5353,7 +5491,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.pending_transcript_open, None);
|
||||
assert!(!state.transcript_cells.contains_key(&thread_id));
|
||||
assert!(!state.transcript_cells.contains_key(&key));
|
||||
assert!(
|
||||
cancellation
|
||||
.lock()
|
||||
@@ -5383,7 +5521,7 @@ mod tests {
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.pending_transcript_open = Some(ThreadId::new());
|
||||
state.pending_transcript_open = Some((state.backend_id, ThreadId::new()));
|
||||
|
||||
let selection = state
|
||||
.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
|
||||
@@ -5409,7 +5547,7 @@ mod tests {
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
let thread_id = ThreadId::new();
|
||||
state.pending_transcript_open = Some(thread_id);
|
||||
state.pending_transcript_open = Some((state.backend_id, thread_id));
|
||||
state.filtered_rows = vec![
|
||||
Row {
|
||||
backend_id: None,
|
||||
@@ -5543,11 +5681,11 @@ mod tests {
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
let loader = page_only_loader(|_| {});
|
||||
let (tx, mut receipts) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut state = PickerState::new(
|
||||
crate::backend_id::BackendId::new(),
|
||||
FrameRequester::test_dummy(),
|
||||
loader,
|
||||
Arc::new(move |request| tx.send(request).unwrap()),
|
||||
ProviderFilter::MatchDefault(String::from("openai")),
|
||||
/*show_all*/ true,
|
||||
/*filter_cwd*/ None,
|
||||
@@ -5561,23 +5699,32 @@ mod tests {
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
state.pending_transcript_open = Some(thread_id);
|
||||
let owner = BackendId::new();
|
||||
let key = (owner, thread_id);
|
||||
state.pending_transcript_open = Some(key);
|
||||
let cells: TranscriptCells =
|
||||
vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))];
|
||||
|
||||
state
|
||||
.transcript_cells
|
||||
.insert(key, SessionTranscriptState::Loading);
|
||||
state
|
||||
.handle_background_event(BackgroundEvent::Transcript {
|
||||
backend_id: owner,
|
||||
thread_id,
|
||||
transcript: Ok(cells),
|
||||
transcript: Ok(LoadedTranscript {
|
||||
cells,
|
||||
unread_revision: Some("observed".into()),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.pending_transcript_open, Some(thread_id));
|
||||
assert_eq!(state.pending_transcript_open, Some(key));
|
||||
assert!(matches!(
|
||||
state.transcript_cells.get(&thread_id),
|
||||
Some(SessionTranscriptState::Loaded(_))
|
||||
state.transcript_cells.get(&key),
|
||||
Some(SessionTranscriptState::Loaded(..))
|
||||
));
|
||||
|
||||
assert!(state.note_transcript_loading_frame_drawn());
|
||||
@@ -5595,6 +5742,16 @@ mod tests {
|
||||
assert_eq!(state.route_key_chord(select), Some(select));
|
||||
assert!(!state.chord_matcher.is_pending());
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("tui");
|
||||
assert!(receipts.try_recv().is_err());
|
||||
tui.defer_screen_size(Size::new(80, 24));
|
||||
tui.screen_size_for_event(&TuiEvent::Draw).unwrap();
|
||||
state
|
||||
.handle_overlay_event(&mut tui, TuiEvent::Draw)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(receipts.try_recv(), Ok(PickerLoadRequest::Acknowledge { backend_id, thread_id: id, revision })
|
||||
if backend_id == owner && id == thread_id && revision == "observed")
|
||||
);
|
||||
state
|
||||
.handle_overlay_event(
|
||||
&mut tui,
|
||||
@@ -5671,11 +5828,13 @@ mod tests {
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
}];
|
||||
let key = (state.backend_id, thread_id);
|
||||
state.transcript_cells.insert(
|
||||
thread_id,
|
||||
SessionTranscriptState::Loaded(vec![Arc::new(PlainHistoryCell::new(vec![
|
||||
"transcript".into(),
|
||||
]))]),
|
||||
key,
|
||||
SessionTranscriptState::Loaded(LoadedTranscript {
|
||||
cells: vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))],
|
||||
unread_revision: None,
|
||||
}),
|
||||
);
|
||||
|
||||
state
|
||||
@@ -5684,7 +5843,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(state.overlay.is_none());
|
||||
assert_eq!(state.pending_transcript_open, Some(thread_id));
|
||||
assert_eq!(state.pending_transcript_open, Some(key));
|
||||
|
||||
assert!(state.note_transcript_loading_frame_drawn());
|
||||
state.open_pending_transcript_if_ready();
|
||||
@@ -6068,6 +6227,7 @@ session_picker_view = "dense"
|
||||
#[test]
|
||||
fn dense_selected_summary_line_uses_full_width_selection_style() {
|
||||
let line = dense_summary_line(DenseSummaryInput {
|
||||
unread: false,
|
||||
marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false),
|
||||
date: "15m ago",
|
||||
title: "Selected dense row",
|
||||
@@ -6086,6 +6246,7 @@ session_picker_view = "dense"
|
||||
#[test]
|
||||
fn dense_zebra_summary_line_uses_full_width_background() {
|
||||
let line = dense_summary_line(DenseSummaryInput {
|
||||
unread: false,
|
||||
marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false),
|
||||
date: "15m ago",
|
||||
title: "Zebra dense row",
|
||||
@@ -7198,7 +7359,7 @@ session_picker_view = "dense"
|
||||
use crate::thread_transcript::thread_to_transcript_cells;
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
let thread = Thread {
|
||||
let mut thread = Thread {
|
||||
originator: None,
|
||||
environments: None,
|
||||
id: thread_id.to_string(),
|
||||
@@ -7264,6 +7425,18 @@ session_picker_view = "dense"
|
||||
}],
|
||||
};
|
||||
|
||||
thread.turns.push(codex_app_server_protocol::Turn {
|
||||
id: String::from("turn-2"),
|
||||
items: Vec::new(),
|
||||
status: codex_app_server_protocol::TurnStatus::Failed,
|
||||
error: Some(codex_app_server_protocol::TurnError {
|
||||
message: String::from("Connection lost"),
|
||||
codex_error_info: None,
|
||||
additional_details: None,
|
||||
misalignment: None,
|
||||
}),
|
||||
..thread.turns[0].clone()
|
||||
});
|
||||
let rendered = thread_to_transcript_cells(
|
||||
thread,
|
||||
RawReasoningVisibility::Visible,
|
||||
@@ -7279,6 +7452,7 @@ session_picker_view = "dense"
|
||||
assert!(rendered.contains("hello from assistant"));
|
||||
assert!(rendered.contains("Proposed Plan"));
|
||||
assert!(rendered.contains("Do the thing"));
|
||||
insta::assert_snapshot!(rendered.lines().last().unwrap(), @"■ Connection lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,11 +107,14 @@ impl PickerState {
|
||||
// Keep the seen-row key as a tombstone so an older page that is already
|
||||
// in flight cannot put the archived session back into the picker.
|
||||
self.transcript_previews.remove(&thread_id);
|
||||
self.transcript_cells.remove(&thread_id);
|
||||
self.transcript_cells.retain(|(_, id), _| *id != thread_id);
|
||||
if self.expanded_thread_id == Some(thread_id) {
|
||||
self.expanded_thread_id = None;
|
||||
}
|
||||
if self.pending_transcript_open == Some(thread_id) {
|
||||
if self
|
||||
.pending_transcript_open
|
||||
.is_some_and(|(_, id)| id == thread_id)
|
||||
{
|
||||
self.pending_transcript_open = None;
|
||||
if let Some(cancellation) = self.pending_transcript_cancellation.take() {
|
||||
let _ = cancellation.send(());
|
||||
|
||||
@@ -16,6 +16,7 @@ struct PendingPage {
|
||||
fn completion_page(catalog: &catalog::ThreadCatalog, failed: &HashSet<BackendId>) -> PickerPage {
|
||||
PickerPage {
|
||||
rows: Vec::new(),
|
||||
read_states: HashMap::new(),
|
||||
history_modes: HashMap::new(),
|
||||
next_cursor: catalog.next_cursor(failed),
|
||||
num_scanned_files: 0,
|
||||
@@ -109,6 +110,7 @@ pub(super) fn spawn(
|
||||
request => {
|
||||
let backend = match &request {
|
||||
PickerLoadRequest::Preview { backend_id, .. }
|
||||
| PickerLoadRequest::Acknowledge { backend_id, .. }
|
||||
| PickerLoadRequest::Transcript { backend_id, .. }
|
||||
| PickerLoadRequest::Archive { backend_id, .. }
|
||||
| PickerLoadRequest::Unarchive { backend_id, .. } => *backend_id,
|
||||
@@ -120,7 +122,8 @@ pub(super) fn spawn(
|
||||
let error = std::io::Error::other("The session's backend is unavailable.");
|
||||
let event = match request {
|
||||
PickerLoadRequest::Preview { thread_id, .. } => BackgroundEvent::Preview { thread_id, preview: Err(error) },
|
||||
PickerLoadRequest::Transcript { thread_id, .. } => BackgroundEvent::Transcript { thread_id, transcript: Err(error) },
|
||||
PickerLoadRequest::Acknowledge { .. } => continue,
|
||||
PickerLoadRequest::Transcript { thread_id, .. } => BackgroundEvent::Transcript { backend_id: backend, thread_id, transcript: Err(error) },
|
||||
PickerLoadRequest::Archive { thread_id, .. } => BackgroundEvent::Archive { thread_id, result: Err(error) },
|
||||
PickerLoadRequest::Unarchive { thread_id, .. } => BackgroundEvent::Unarchive { thread_id, result: Err(error) },
|
||||
PickerLoadRequest::Page(_) => unreachable!(),
|
||||
|
||||
@@ -35,6 +35,9 @@ fn thread_colors_and_selection_contrast_in_both_picker_layouts() {
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state
|
||||
.unread
|
||||
.insert((state.backend_id, row.thread_id.unwrap()), "rev".into());
|
||||
let (fg, bg) = ((0, 0, 0), (255, 255, 255));
|
||||
with_test_default_colors(DefaultColors { fg, bg }, || {
|
||||
for density in [SessionListDensity::Comfortable, SessionListDensity::Dense] {
|
||||
@@ -64,7 +67,9 @@ fn thread_colors_and_selection_contrast_in_both_picker_layouts() {
|
||||
SessionListDensity::Comfortable => 2,
|
||||
SessionListDensity::Dense => 14,
|
||||
};
|
||||
let title_cell = &buffer[(title_x, 0)];
|
||||
let dot = &buffer[(title_x, 0)];
|
||||
assert_eq!((dot.symbol(), dot.fg), ("●", rgb_color((0x3b, 0x82, 0xf6))));
|
||||
let title_cell = &buffer[(title_x + 2, 0)];
|
||||
if use_colors {
|
||||
assert_eq!(
|
||||
title_cell.fg,
|
||||
@@ -235,24 +240,23 @@ fn compact_picker_keeps_metadata_and_labeled_primary_actions() {
|
||||
|
||||
#[test]
|
||||
fn reversed_selection_overrides_thread_color() {
|
||||
let line = apply_line_background(
|
||||
Line::from("Title".fg(rgb_color((150, 50, 170)))),
|
||||
Style::default()
|
||||
.fg(Color::Reset)
|
||||
.bg(Color::Reset)
|
||||
.reversed(),
|
||||
let row = Style::default()
|
||||
.fg(Color::Reset)
|
||||
.bg(Color::Reset)
|
||||
.reversed();
|
||||
let line = session_summary_line(
|
||||
vec![],
|
||||
"Title".fg(rgb_color((150, 50, 170))),
|
||||
/*unread*/ true,
|
||||
row,
|
||||
/*width*/ 12,
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
line.spans[0].style.fg,
|
||||
line.spans[0]
|
||||
.style
|
||||
.add_modifier
|
||||
.contains(Modifier::REVERSED)
|
||||
),
|
||||
(Some(Color::Reset), true)
|
||||
);
|
||||
let dot = &line.spans[0];
|
||||
let title = &line.spans[2];
|
||||
assert_eq!(dot.content, "●");
|
||||
assert_eq!(dot.style, row.bg(rgb_color((0x3b, 0x82, 0xf6))));
|
||||
assert_eq!(title.style.fg, row.fg);
|
||||
assert!(title.style.add_modifier.contains(Modifier::REVERSED));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -47,6 +47,7 @@ async fn later_page_error_preserves_loaded_sessions() {
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
}],
|
||||
read_states: HashMap::new(),
|
||||
history_modes: HashMap::new(),
|
||||
next_cursor: Some(PageCursor::AppServer("next-page".to_string())),
|
||||
num_scanned_files: 1,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: snapshot
|
||||
Fix resume picker timestamps
|
||||
42s ago ⌁ no cwd no branch
|
||||
|
||||
› Investigate lazy pagination cap
|
||||
› ● Investigate lazy pagination cap
|
||||
35m ago ⌁ no cwd no branch
|
||||
|
||||
Explain the codebase
|
||||
|
||||
@@ -103,13 +103,19 @@ pub(crate) fn thread_to_transcript_cells(
|
||||
.turns
|
||||
.into_iter()
|
||||
.flat_map(|turn| {
|
||||
thread_items_to_transcript_cells(
|
||||
let mut cells = thread_items_to_transcript_cells(
|
||||
thread_id,
|
||||
&cwd,
|
||||
turn.items.into_iter().map(|entry| entry.payload),
|
||||
raw_reasoning_visibility,
|
||||
config,
|
||||
)
|
||||
);
|
||||
if let Some(error) = turn.error {
|
||||
cells.push(Arc::new(crate::history_cell::new_error_event(
|
||||
error.message,
|
||||
)));
|
||||
}
|
||||
cells
|
||||
})
|
||||
.collect::<TranscriptCells>();
|
||||
if cells.is_empty() {
|
||||
|
||||
Reference in New Issue
Block a user