diff --git a/crates/ui/src/chat/components/changed_files.rs b/crates/ui/src/chat/components/changed_files.rs index 8c465137..88d68a91 100644 --- a/crates/ui/src/chat/components/changed_files.rs +++ b/crates/ui/src/chat/components/changed_files.rs @@ -5,13 +5,16 @@ use crate::theme::ActiveTheme as _; use crate::widgets::tooltip::Tooltip; use agent::{ChangeCompleteness, FileChange}; use gpui::{ - AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement as _, IntoElement as _, - ParentElement as _, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _, - Window, div, prelude::FluentBuilder as _, px, + AnyElement, App, ClickEvent, Div, ElementId, HighlightStyle, InteractiveElement as _, + IntoElement as _, ParentElement as _, Role, SharedString, Stateful, + StatefulInteractiveElement as _, Styled as _, StyledText, Window, div, + prelude::FluentBuilder as _, px, }; use gpui_base::{StyledExt as _, h_flex, v_flex}; use super::super::model::{LiveEditRow, diff_stats}; +use crate::diff::model::{DiffColors, FileDiffInput, RenderedRow, build_file}; +use crate::diff::parse::RowKind; pub(crate) type ClickHandler = Box; @@ -228,11 +231,56 @@ impl FileEditRowStyle { } } -/// One live file-edit row: "Code edit src/foo.rs +12 -3". -pub(crate) fn file_edit_row(row: &LiveEditRow, style: &FileEditRowStyle) -> Div { +/// One live file-edit row: "Code edit src/foo.rs +12 -3". Provider diffs +/// drill down in place so the active edit can show its actual patch. +pub(crate) fn file_edit_row( + key: &str, + row: &LiveEditRow, + expanded: bool, + on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + let style = FileEditRowStyle::from_theme(cx); + let expandable = row.counts.is_some(); + let header = file_edit_row_header(row, expanded, expandable, &style); + let header: AnyElement = if expandable { + crate::material::accessible_clickable( + header, + SharedString::from(format!("file-edit-row-{key}")), + Role::Button, + crate::tr!("chat.activity_details"), + cx, + ) + .aria_expanded(expanded) + .rounded(crate::material::radius_chip()) + .cursor_pointer() + .hover(|header| header.bg(cx.theme().accent)) + .on_click(on_toggle) + .into_any_element() + } else { + header.into_any_element() + }; + + v_flex() + .w_full() + .gap_1() + .child(header) + .when(expanded && expandable, |content| { + content.child(render_inline_diff(key, row, cx)) + }) + .into_any_element() +} + +fn file_edit_row_header( + row: &LiveEditRow, + expanded: bool, + expandable: bool, + style: &FileEditRowStyle, +) -> Div { h_flex() .w_full() .min_h(px(28.)) + .px_1() .gap_2() .items_center() .text_size(px(12.5)) @@ -276,6 +324,117 @@ pub(crate) fn file_edit_row(row: &LiveEditRow, style: &FileEditRowStyle) -> Div .debug_selector(|| "file-edit-counts".into()), ) }) + .when(expandable, |element| { + element.child( + Icon::new(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .size(px(13.)) + .text_color(style.muted), + ) + }) +} + +fn render_inline_diff(key: &str, row: &LiveEditRow, cx: &App) -> AnyElement { + let rendered = build_file( + &FileDiffInput { + path: &row.path, + kind: row.kind, + old_text: None, + new_text: None, + patch: row.diff.as_deref(), + ignore_whitespace: false, + show_invisibles: false, + }, + row.path.clone(), + crate::highlight::language_name_for_path(&row.path), + &cx.theme().highlight_theme, + &DiffColors { + added_word_bg: cx.theme().success.opacity(0.30), + removed_word_bg: cx.theme().danger.opacity(0.28), + }, + &HighlightStyle::default(), + ); + let rows = rendered + .all_rows + .iter() + .map(|row| render_inline_diff_row(row, cx)) + .collect::>(); + + div() + .w_full() + .ml_2() + .pl(px(14.)) + .py_0p5() + .border_l_1() + .border_color(cx.theme().border) + .debug_selector(|| "file-edit-diff".into()) + .child( + div() + .id(SharedString::from(format!("file-edit-diff-y-{key}"))) + .w_full() + .max_h(px(240.)) + .overflow_y_scroll() + .child( + div() + .id(SharedString::from(format!("file-edit-diff-x-{key}"))) + .w_full() + .overflow_x_scroll() + .child( + v_flex() + .min_w_full() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.5)) + .children(rows), + ), + ), + ) + .into_any_element() +} + +fn render_inline_diff_row(row: &RenderedRow, cx: &App) -> AnyElement { + let (background, accent) = match row.kind { + RowKind::Added => ( + Some(cx.theme().success.opacity(0.13)), + Some(cx.theme().success), + ), + RowKind::Removed => ( + Some(cx.theme().danger.opacity(0.12)), + Some(cx.theme().danger), + ), + RowKind::Context => (None, None), + }; + let gutter = |line: Option| { + div() + .flex_none() + .w(px(36.)) + .px_1() + .text_right() + .text_size(px(10.5)) + .text_color(cx.theme().muted_foreground) + .child(line.map(|line| line.to_string()).unwrap_or_default()) + }; + + h_flex() + .min_w_full() + .min_h(px(18.)) + .items_start() + .border_l_2() + .border_color(accent.unwrap_or(gpui::transparent_black())) + .when_some(background, |line, background| line.bg(background)) + .child(gutter(row.old)) + .child(gutter(row.new)) + .child( + div() + .flex_1() + .px_2() + .whitespace_nowrap() + .text_color(cx.theme().foreground) + .child(StyledText::new(row.text.clone()).with_highlights(row.runs.iter().cloned())), + ) + .into_any_element() } #[cfg(test)] @@ -286,6 +445,7 @@ mod tests { struct FileEditRowProbe { row: LiveEditRow, + expanded: bool, } impl gpui::Render for FileEditRowProbe { @@ -298,9 +458,13 @@ mod tests { // content-height there; reproduce that rather than letting the // window stretch the row and mask a wrap. use gpui::{ParentElement as _, Styled as _}; - gpui_base::v_flex() - .size_full() - .child(file_edit_row(&self.row, &FileEditRowStyle::from_theme(cx))) + gpui_base::v_flex().size_full().child(file_edit_row( + "test-file-edit", + &self.row, + self.expanded, + |_, _, _| {}, + cx, + )) } } @@ -312,8 +476,11 @@ mod tests { let (_, cx) = cx.add_window_view(|_, _| FileEditRowProbe { row: LiveEditRow { path: "crates/ui/src/deeply/nested/module/tree/with/an/absurdly/long/name/live_file_edit_row.rs".into(), + kind: agent::FileChangeKind::Modify, counts: Some((128, 96)), + diff: None, }, + expanded: false, }); let cx: &mut VisualTestContext = cx; let draw = |cx: &mut VisualTestContext| { @@ -364,6 +531,33 @@ mod tests { } } + #[gpui::test] + fn expanded_file_edit_renders_its_provider_diff(cx: &mut TestAppContext) { + use gpui::{VisualTestContext, px, size}; + + cx.update(crate::theme::init); + let (_, cx) = cx.add_window_view(|_, _| FileEditRowProbe { + row: LiveEditRow { + path: "src/lib.rs".into(), + kind: agent::FileChangeKind::Modify, + counts: Some((1, 1)), + diff: Some("@@ -1,2 +1,2 @@\n-fn old() {}\n+fn new() {}\n fn stable() {}\n".into()), + }, + expanded: true, + }); + let cx: &mut VisualTestContext = cx; + cx.simulate_resize(size(px(640.), px(240.))); + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + }); + + let diff = cx + .debug_bounds("file-edit-diff") + .expect("expanded inline diff bounds"); + assert!(diff.size.height >= px(36.)); + } + #[test] fn live_edit_row_label_is_exact_in_both_locales() { let _locale_guard = crate::settings::TestLocaleGuard::acquire(); diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index 3124b6a2..12503cad 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -68,65 +68,66 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.; const TURN_GAP: f32 = 32.; /// Large documents are parsed away from the UI executor before becoming resident. const ASYNC_MARKDOWN_THRESHOLD_BYTES: usize = 4 * 1024; -/// Keep a just-finished live command visible long enough for its final output -/// to register before the automatically expanded panel folds away. -const AUTO_COMMAND_COLLAPSE_DELAY: Duration = Duration::from_millis(500); +/// Keep a just-settled live activity visible long enough for its final output +/// or rendered diff to register before the automatically expanded detail folds. +const AUTO_ACTIVITY_COLLAPSE_DELAY: Duration = Duration::from_millis(500); #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutoCommandExpansion { - Running, +enum AutoActivityExpansion { + Expanded, CollapsePending(u64), + Collapsed, } #[derive(Debug, Default)] -struct AutoCommandExpansions { - entries: HashMap, +struct AutoActivityExpansions { + entries: HashMap, next_generation: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct AutoCommandObservation { +struct AutoActivityObservation { expanded: bool, collapse_generation: Option, } -impl AutoCommandExpansions { - fn observe(&mut self, key: &str, enabled: bool, running: bool) -> AutoCommandObservation { +impl AutoActivityExpansions { + fn observe(&mut self, key: &str, enabled: bool, active: bool) -> AutoActivityObservation { if !enabled { self.entries.remove(key); - return AutoCommandObservation { + return AutoActivityObservation { expanded: false, collapse_generation: None, }; } - if running { + if active { self.entries - .insert(key.to_string(), AutoCommandExpansion::Running); - return AutoCommandObservation { + .insert(key.to_string(), AutoActivityExpansion::Expanded); + return AutoActivityObservation { expanded: true, collapse_generation: None, }; } match self.entries.get(key).copied() { - Some(AutoCommandExpansion::Running) => { + Some(AutoActivityExpansion::Expanded) | None => { self.next_generation = self.next_generation.wrapping_add(1); let generation = self.next_generation; self.entries.insert( key.to_string(), - AutoCommandExpansion::CollapsePending(generation), + AutoActivityExpansion::CollapsePending(generation), ); - AutoCommandObservation { + AutoActivityObservation { expanded: true, collapse_generation: Some(generation), } } - Some(AutoCommandExpansion::CollapsePending(_)) => AutoCommandObservation { + Some(AutoActivityExpansion::CollapsePending(_)) => AutoActivityObservation { expanded: true, collapse_generation: None, }, - None => AutoCommandObservation { + Some(AutoActivityExpansion::Collapsed) => AutoActivityObservation { expanded: false, collapse_generation: None, }, @@ -134,8 +135,9 @@ impl AutoCommandExpansions { } fn finish_collapse(&mut self, key: &str, generation: u64) -> bool { - if self.entries.get(key) == Some(&AutoCommandExpansion::CollapsePending(generation)) { - self.entries.remove(key); + if self.entries.get(key) == Some(&AutoActivityExpansion::CollapsePending(generation)) { + self.entries + .insert(key.to_string(), AutoActivityExpansion::Collapsed); true } else { false @@ -169,7 +171,7 @@ pub struct ChatView { markdown_scroll_top: Option, /// Open/closed keys for collapsibles (work logs, activity rows, cards, files). expanded: HashSet, - auto_command_expansions: AutoCommandExpansions, + auto_activity_expansions: AutoActivityExpansions, command_panels: RefCell, session_key: Option, /// Turn selected from a command-palette content hit. @@ -238,7 +240,7 @@ impl ChatView { markdown_visible_turns: 0..0, markdown_scroll_top: None, expanded: HashSet::new(), - auto_command_expansions: AutoCommandExpansions::default(), + auto_activity_expansions: AutoActivityExpansions::default(), command_panels: RefCell::new(CommandPanelCache::new()), session_key: None, highlighted_turn: None, @@ -260,7 +262,7 @@ impl ChatView { let session_changed = session_key != self.session_key; if session_changed { self.expanded.clear(); - self.auto_command_expansions.clear(); + self.auto_activity_expansions.clear(); } let (running, list_sync) = self .workspace_store @@ -614,6 +616,35 @@ impl ChatView { cx.notify(); } + fn auto_activity_expanded( + &mut self, + turn: usize, + key: &str, + enabled: bool, + active: bool, + cx: &mut Context, + ) -> bool { + let auto = self.auto_activity_expansions.observe(key, enabled, active); + if let Some(generation) = auto.collapse_generation { + let collapse_key = key.to_string(); + let timer = cx.background_executor().timer(AUTO_ACTIVITY_COLLAPSE_DELAY); + cx.spawn(async move |this, cx| { + timer.await; + let _ = this.update(cx, |this, cx| { + if this + .auto_activity_expansions + .finish_collapse(&collapse_key, generation) + { + this.list_state.remeasure_items(turn..turn + 1); + cx.notify(); + } + }); + }) + .detach(); + } + auto.expanded + } + // -- turn rendering ----------------------------------------------------- /// Render one turn as chronological messages, errors, and Work Log runs. @@ -1051,7 +1082,7 @@ impl ChatView { format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000); let outcome = work_log_outcome(turn, folded, is_last); let rows = if expanded { - self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx) + self.compose_work_log_rows(folded, cwd, live_reasoning_id, false, cx) } else { Vec::new() }; @@ -1080,7 +1111,13 @@ impl ChatView { v_flex() .w_full() .gap_1() - .children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)), + .children(self.compose_work_log_rows( + visible, + cwd, + live_reasoning_id, + running, + cx, + )), ); } @@ -1092,25 +1129,39 @@ impl ChatView { activities: &[&TimelineEntry], cwd: &Path, live_reasoning_id: Option<&str>, + auto_expand: bool, cx: &mut Context, ) -> Vec { let mut rows = Vec::new(); - for entry in activities { - if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content { - for row in live_edit_rows(changes, cwd) { - rows.push( - components::changed_files::file_edit_row( - &row, - &components::changed_files::FileEditRowStyle::from_theme(cx), - ) - .into_any_element(), - ); + for (activity_index, entry) in activities.iter().enumerate() { + let latest = activity_index + 1 == activities.len(); + if let EntryContent::Item(ItemContent::FileChange { changes, status }) = &entry.content + { + let turn = entry.turn; + for (file_index, row) in live_edit_rows(changes, cwd).iter().enumerate() { + let key = format!("activity-{}-file-{file_index}", entry.id); + let enabled = auto_expand && row.counts.is_some(); + let active = latest && *status == ItemStatus::InProgress; + let expanded = self.auto_activity_expanded(turn, &key, enabled, active, cx) + || self.expanded.contains(&key); + let toggle_key = key.clone(); + rows.push(components::changed_files::file_edit_row( + &key, + row, + expanded, + cx.listener(move |this, _, _, cx| { + this.toggle_expanded(turn, &toggle_key, cx); + }), + cx, + )); } } else { rows.push(self.compose_activity_row( entry, false, live_reasoning_id == Some(entry.id.as_str()), + auto_expand, + latest, cx, )); } @@ -1124,6 +1175,8 @@ impl ChatView { entry: &TimelineEntry, compact: bool, live_reasoning: bool, + auto_expand: bool, + latest: bool, cx: &mut Context, ) -> AnyElement { if matches!( @@ -1140,28 +1193,11 @@ impl ChatView { } _ => (false, false), }; - let auto_enabled = is_command && self.workspace_store.read(cx).live_command_panel(); - let auto = self - .auto_command_expansions - .observe(&key, auto_enabled, running); - if let Some(generation) = auto.collapse_generation { - let collapse_key = key.clone(); - let timer = cx.background_executor().timer(AUTO_COMMAND_COLLAPSE_DELAY); - cx.spawn(async move |this, cx| { - timer.await; - let _ = this.update(cx, |this, cx| { - if this - .auto_command_expansions - .finish_collapse(&collapse_key, generation) - { - this.list_state.remeasure_items(turn..turn + 1); - cx.notify(); - } - }); - }) - .detach(); - } - let expanded = auto.expanded || self.expanded.contains(&key); + let auto_enabled = + auto_expand && is_command && self.workspace_store.read(cx).live_command_panel(); + let auto_expanded = + self.auto_activity_expanded(turn, &key, auto_enabled, latest && running, cx); + let expanded = auto_expanded || self.expanded.contains(&key); let command_detail = if expanded { match &entry.content { EntryContent::Item(ItemContent::CommandExecution { @@ -2216,7 +2252,7 @@ fn open_in_zed(cwd: &Path, window: &mut Window, cx: &mut App) { #[cfg(test)] mod tests { use super::{ - ASYNC_MARKDOWN_THRESHOLD_BYTES, AUTO_COMMAND_COLLAPSE_DELAY, AutoCommandExpansions, + ASYNC_MARKDOWN_THRESHOLD_BYTES, AUTO_ACTIVITY_COLLAPSE_DELAY, AutoActivityExpansions, ChatView, ResidencyScope, markdown_entries_for_residency, }; use crate::store::WorkspaceStore; @@ -2232,8 +2268,8 @@ mod tests { static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0); #[test] - fn completed_live_command_stays_expanded_until_delayed_collapse() { - let mut expansions = AutoCommandExpansions::default(); + fn settled_live_activity_stays_expanded_until_delayed_collapse() { + let mut expansions = AutoActivityExpansions::default(); let running = expansions.observe("activity-command", true, true); assert!(running.expanded); @@ -2251,14 +2287,27 @@ mod tests { assert!(expansions.finish_collapse("activity-command", generation)); assert!(!expansions.observe("activity-command", true, false).expanded); assert_eq!( - AUTO_COMMAND_COLLAPSE_DELAY, + AUTO_ACTIVITY_COLLAPSE_DELAY, std::time::Duration::from_millis(500) ); } #[test] - fn restarted_command_cancels_its_stale_delayed_collapse() { - let mut expansions = AutoCommandExpansions::default(); + fn activity_first_seen_settled_still_opens_for_the_collapse_delay() { + let mut expansions = AutoActivityExpansions::default(); + let settled = expansions.observe("activity-command", true, false); + + assert!(settled.expanded); + let generation = settled + .collapse_generation + .expect("first observation should schedule a collapse"); + assert!(expansions.finish_collapse("activity-command", generation)); + assert!(!expansions.observe("activity-command", true, false).expanded); + } + + #[test] + fn reactivated_activity_cancels_its_stale_delayed_collapse() { + let mut expansions = AutoActivityExpansions::default(); expansions.observe("activity-command", true, true); let generation = expansions .observe("activity-command", true, false) diff --git a/crates/ui/src/chat/model.rs b/crates/ui/src/chat/model.rs index a36361cc..a5e3b0d2 100644 --- a/crates/ui/src/chat/model.rs +++ b/crates/ui/src/chat/model.rs @@ -530,7 +530,9 @@ pub(crate) fn diff_stats(diff: Option<&str>) -> (u32, u32) { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LiveEditRow { pub(crate) path: String, + pub(crate) kind: agent::FileChangeKind, pub(crate) counts: Option<(u32, u32)>, + pub(crate) diff: Option, } /// The `+N` / `-N` a live edit row should display, if any. @@ -552,7 +554,9 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec { + EntryContent::Item(ItemContent::FileChange { changes, status }) => { changes.len().hash(hash); for change in changes { change.path.len().hash(hash); change.diff.as_ref().map(String::len).hash(hash); } + std::mem::discriminant(status).hash(hash); } EntryContent::Item(ItemContent::ToolCall { name, @@ -1514,6 +1519,38 @@ mod tests { ); } + #[test] + fn file_change_status_change_remeasures_the_turn() { + let turns = vec![TurnMeta::default()]; + let entries = vec![entry( + "edit", + EntryContent::Item(ItemContent::FileChange { + changes: vec![FileChange { + path: "src/lib.rs".into(), + kind: FileChangeKind::Modify, + diff: Some(REAL_DIFF.into()), + }], + status: ItemStatus::InProgress, + }), + )]; + let running = index_turns(&turns, &entries, None, &HashSet::new()); + let mut completed_entries = entries; + if let EntryContent::Item(ItemContent::FileChange { status, .. }) = + &mut Arc::make_mut(&mut completed_entries[0]).content + { + *status = ItemStatus::Completed; + } + let completed = index_turns(&turns, &completed_entries, None, &HashSet::new()); + + assert_eq!( + list_sync(&running, &completed, false), + ListSync::Incremental { + append: None, + remeasure: vec![0], + } + ); + } + #[test] fn segment_entries_preserves_interleaved_timeline_order() { let entries = [ @@ -2001,11 +2038,15 @@ mod tests { vec![ LiveEditRow { path: "src/foo.rs".into(), + kind: FileChangeKind::Modify, counts: Some((2, 1)), + diff: Some(REAL_DIFF.into()), }, LiveEditRow { path: "src/bar.rs".into(), + kind: FileChangeKind::Create, counts: None, + diff: Some(String::new()), }, ] );