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:
Eric Traut
2026-10-30 06:45:04 +00:00
committed by copyberry
parent 025f6d715d
commit d7b77a5e74
8 changed files with 300 additions and 108 deletions
+1
View File
@@ -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;
+259 -85
View File
@@ -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]
+5 -2
View File
@@ -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(());
+4 -1
View File
@@ -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!(),
+21 -17
View File
@@ -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,
@@ -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
+8 -2
View File
@@ -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() {