Keep fork lineage ahead of paginated transcript history (#54044)

## Why

Loading older history could place transcript entries before the `Thread forked from` notice, separating fork lineage from the session header.

## What changed

Treat fork lineage as a session prefix in both the TUI transcript and transcript overlay so older pages are inserted after it.

## Testing

Add regression tests for repeated history prepends in both paths, verifying that fork lineage stays first.

GitOrigin-RevId: 71497fb7ffa992c72ee08617d318aa3119e71275
This commit is contained in:
Felipe Coury
2026-10-14 17:25:26 +00:00
committed by copyberry
parent 5388581653
commit cec8c6fb87
6 changed files with 90 additions and 13 deletions
+2 -6
View File
@@ -6,9 +6,8 @@ use std::ops::Range;
use super::*;
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
use crate::app_server_session::thread_items_page_params;
use crate::history_cell::SessionHeaderHistoryCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::is_session_history_prefix_cell;
use crate::pager_overlay::TranscriptHistoryState;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_items_to_transcript_cells;
@@ -265,10 +264,7 @@ impl App {
} else {
self.transcript_cells
.iter()
.rposition(|cell| {
cell.as_any().is::<SessionInfoCell>()
|| cell.as_any().is::<SessionHeaderHistoryCell>()
})
.rposition(|cell| is_session_history_prefix_cell(cell.as_ref()))
.map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1))
};
let inserted = index..index + cells.len();
@@ -4,6 +4,7 @@ use super::*;
use crate::app::test_support::make_test_app;
use crate::history_cell::ComputerActivityCell;
use crate::history_cell::FinalMessageSeparator;
use crate::history_cell::ForkedThreadHistoryCell;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::UserInput;
use pretty_assertions::assert_eq;
@@ -60,6 +61,45 @@ fn user_cell(message: &str) -> Arc<dyn HistoryCell> {
})
}
#[tokio::test]
async fn fork_lineage_stays_before_repeated_older_history_pages() {
let mut app = make_test_app().await;
app.transcript_cells = vec![
Arc::new(ForkedThreadHistoryCell(
crate::history_cell::PlainHistoryCell::new(vec!["Thread forked from parent".into()]),
)),
Arc::new(crate::history_cell::PlainHistoryCell::new(vec![
"recent history".into(),
])),
];
app.prepend_older_transcript_cells(vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
vec!["older history".into()],
))]);
app.prepend_older_transcript_cells(vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
vec!["oldest history".into()],
))]);
let rendered = app
.transcript_cells
.iter()
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered, @r###"
Thread forked from parent
oldest history
older history
recent history
"###);
}
#[test]
fn repeated_prompt_matching_removes_only_the_hidden_occurrence() {
let cells = vec![user_cell("repeat"), user_cell("other"), user_cell("repeat")];
+2 -1
View File
@@ -3,6 +3,7 @@
//! checks the previous model when it applies the final selection.
use super::*;
use crate::history_cell::ForkedThreadHistoryCell;
use crate::style::accent_color;
impl ChatWidget {
@@ -310,7 +311,7 @@ impl ChatWidget {
.into()
};
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
PlainHistoryCell::new(vec![line]),
ForkedThreadHistoryCell(PlainHistoryCell::new(vec![line])),
)));
}
+21
View File
@@ -103,6 +103,20 @@ impl HistoryCell for SessionNoticeCell {
}
}
/// Fork lineage that stays with the session header when older history is prepended.
#[derive(Debug)]
pub(crate) struct ForkedThreadHistoryCell(pub(crate) PlainHistoryCell);
impl HistoryCell for ForkedThreadHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.display_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.0.raw_lines()
}
}
#[derive(Debug)]
pub struct SessionInfoCell(CompositeHistoryCell);
@@ -165,6 +179,13 @@ impl HistoryCell for SessionInfoCell {
}
}
/// Session-level cells that stay ahead of every loaded transcript page.
pub(crate) fn is_session_history_prefix_cell(cell: &dyn HistoryCell) -> bool {
cell.as_any().is::<SessionInfoCell>()
|| cell.as_any().is::<SessionHeaderHistoryCell>()
|| cell.as_any().is::<ForkedThreadHistoryCell>()
}
#[expect(
clippy::too_many_arguments,
reason = "keep local preferences separate while the legacy Config parameter is still required"
+2 -6
View File
@@ -5,8 +5,7 @@
use super::*;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::SessionHeaderHistoryCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::is_session_history_prefix_cell;
use crate::keymap::RuntimeKeymap;
use crate::motion::MotionMode;
use crate::transcript_view::TranscriptBookmark;
@@ -313,10 +312,7 @@ impl TranscriptOverlay {
let index = self
.cells
.iter()
.rposition(|cell| {
cell.as_any().is::<SessionInfoCell>()
|| cell.as_any().is::<SessionHeaderHistoryCell>()
})
.rposition(|cell| is_session_history_prefix_cell(cell.as_ref()))
.map_or(/*default*/ 0, |index| index + 1);
let added = cells.len();
self.cells.splice(index..index, cells);
@@ -60,6 +60,29 @@ fn scrolling_prepend_and_resize_preserve_reading_content() {
assert!(!overlay.is_scrolled_to_bottom());
}
#[test]
fn repeated_prepend_keeps_fork_lineage_first() {
let lineage: Arc<dyn HistoryCell> = Arc::new(history_cell::ForkedThreadHistoryCell(
history_cell::PlainHistoryCell::new(vec!["fork lineage".into()]),
));
let mut overlay = transcript_overlay(vec![
Arc::clone(&lineage),
Arc::new(TestCell {
lines: vec!["recent".into()],
}),
]);
let older = overlay.prepend(vec![Arc::new(TestCell {
lines: vec!["older".into()],
})]);
let oldest = overlay.prepend(vec![Arc::new(TestCell {
lines: vec!["oldest".into()],
})]);
assert_eq!((older, oldest), (1, 1));
assert!(Arc::ptr_eq(&overlay.cells[0], &lineage));
}
#[tokio::test]
async fn activity_hint_uses_the_configured_pager_jump_binding() -> Result<()> {
let mut tui = crate::tui::test_support::make_test_tui()?;