From aac32aec428d0983019dc337dcf5bd1b9ef0ed04 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 29 Sep 2026 16:17:49 +0800 Subject: [PATCH] fix(chat): show a pending steer sent after a message Since the timeline is virtualized by segment (#547), a row read its turn's entries from its first row's start to its last row's end. A pending steer belongs to no segment, so one that followed a message fell outside that span and the trailer never drew it: the steer vanished until the next tool run, whose range reaches the turn's end, covered it again. The turn's span now comes from the timeline itself. --- crates/ui/src/chat/components/bubble.rs | 2 + crates/ui/src/chat/mod.rs | 85 +++++++++++++++++-------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/crates/ui/src/chat/components/bubble.rs b/crates/ui/src/chat/components/bubble.rs index 5b39810a..ba90c46a 100644 --- a/crates/ui/src/chat/components/bubble.rs +++ b/crates/ui/src/chat/components/bubble.rs @@ -239,8 +239,10 @@ pub(crate) fn user_bubble( .items_end() .gap(px(2.)) .when_some(steering, |column, steering| { + let selector = format!("steering-{entry_id}"); column.child( div() + .debug_selector(move || selector) .h(px(18.)) .px(px(6.)) .mb(px(-2.)) diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index 6c5d996d..37c90883 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -3001,37 +3001,32 @@ impl Render for ChatView { // Clone only the entries of this row's turn: the row renders // its own segment and the turn's last row its trailer. The // full history remains behind the store and is never cloned - // by the render path. - let turn_entries = { - let rows = rows_of_turn(&this.rows, row.turn); - let start = this.rows[rows.start].entry_range.start; - let end = this.rows[rows.end - 1].entry_range.end; - start.min(end)..end - }; - let Some((turn, entries)) = - this.workspace_store - .read(cx) - .with_active_timeline(|timeline| { - ( - timeline.turns.get(row.turn).cloned().unwrap_or_default(), - // The rows are a snapshot that can trail the live - // timeline by a frame (e.g. adopting a running - // background thread whose timeline is being - // re-folded), so they must not index blindly. - timeline - .entries - .get(turn_entries.clone()) - .map(<[_]>::to_vec) - .unwrap_or_default(), - ) - }) + // by the render path. The turn's span comes from the + // timeline, not its rows: a pending steer after a message + // belongs to no row but renders in the trailer. + let Some((turn, entries, turn_start)) = this + .workspace_store + .read(cx) + .with_active_timeline(|timeline| { + let entries = &timeline.entries; + let start = entries.partition_point(|entry| entry.turn < row.turn); + let len = entries[start..].partition_point(|entry| entry.turn == row.turn); + ( + timeline.turns.get(row.turn).cloned().unwrap_or_default(), + entries[start..start + len].to_vec(), + start, + ) + }) else { return div().into_any_element(); }; - // The row's range, relative to the turn's entries. + // The row's range, relative to the turn's entries. The rows + // are a snapshot that can trail the live timeline by a frame + // (e.g. adopting a running background thread whose timeline + // is being re-folded); `render_row` bounds-checks the range. let row = TimelineRow { - entry_range: row.entry_range.start - turn_entries.start - ..row.entry_range.end - turn_entries.start, + entry_range: row.entry_range.start.saturating_sub(turn_start) + ..row.entry_range.end.saturating_sub(turn_start), ..row }; let rendered = this.render_row( @@ -3704,6 +3699,42 @@ mod tests { )); } + #[gpui::test] + fn pending_steer_after_the_last_segment_renders_in_the_trailer(cx: &mut TestAppContext) { + use gpui::{VisualTestContext, px, size}; + use tcode_core::session::SteeringStatus; + + let mut timeline = Timeline::default(); + timeline.turns = vec![TurnMeta { + running: true, + ..TurnMeta::default() + }]; + timeline.entries.extend([ + entry("user", user_item("go")), + entry("assistant", assistant("On it.")), + entry( + "steer", + EntryContent::Steer { + text: "also do this".into(), + status: SteeringStatus::Pending, + context_len: None, + attachments: Vec::new(), + }, + ), + ]); + + let (workspace_store, window_state, _) = seed_chat(cx, timeline); + let (_view, cx) = cx + .add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx)); + let cx: &mut VisualTestContext = cx; + cx.simulate_resize(size(px(1_024.), px(700.))); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + + assert!(cx.debug_bounds("steering-steer").is_some()); + } + #[test] fn collapsed_activity_stays_collapsed_after_visiting_another_session() { let mut expansions = AutoActivityExpansions::default();