diff --git a/app/Cargo.toml b/app/Cargo.toml index a12a0d9cc8..2ea7250654 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -732,6 +732,7 @@ file_retrieval_tools = [] get_started_tab = [] code_mode_chip = [] github_pr_prompt_chip = [] +code_review_comments_chip = [] create_project_flow = [] agent_mode_evals = [ "integration_tests", diff --git a/app/src/ai/agent/comment.rs b/app/src/ai/agent/comment.rs index 9147cc5f69..234c948f5b 100644 --- a/app/src/ai/agent/comment.rs +++ b/app/src/ai/agent/comment.rs @@ -48,6 +48,24 @@ impl ReviewComment { .unwrap_or_else(|| "Review Comment".to_string()), } } + + /// A one-line summary of the comment used for compact list rows. + /// + /// This shows the actual comment text (rather than its file/line location), + /// collapsing any internal whitespace/newlines into single spaces so it renders + /// on a single line, and hard-truncating to `max_chars` with an ellipsis as a + /// defensive bound. Falls back to [`Self::title`] when the comment has no body. + pub fn summary(&self, max_chars: usize) -> String { + let collapsed = self + .content + .split_whitespace() + .collect::>() + .join(" "); + if collapsed.is_empty() { + return self.title(); + } + crate::util::truncation::truncate_from_end(&collapsed, max_chars) + } } impl From for ReviewComment { @@ -100,3 +118,7 @@ pub struct ReviewDiff { pub file_path: Option, pub line_number: Option, } + +#[cfg(test)] +#[path = "comment_tests.rs"] +mod tests; diff --git a/app/src/ai/agent/comment_tests.rs b/app/src/ai/agent/comment_tests.rs new file mode 100644 index 0000000000..cd5792d3a5 --- /dev/null +++ b/app/src/ai/agent/comment_tests.rs @@ -0,0 +1,44 @@ +use crate::ai::agent::comment::{ReviewComment, ReviewDiff}; +use crate::code_review::comments::CommentId; + +fn comment(content: &str, head_title: Option<&str>) -> ReviewComment { + ReviewComment { + id: CommentId::default(), + content: content.to_string(), + diff: ReviewDiff { + file_path: None, + line_number: None, + }, + head_title: head_title.map(str::to_string), + } +} + +#[test] +fn summary_shows_comment_text_when_short() { + let c = comment("Please rename this variable", None); + assert_eq!(c.summary(80), "Please rename this variable"); +} + +#[test] +fn summary_truncates_long_text_with_ellipsis() { + let c = comment("abcdefghij", None); + // truncate_from_end keeps max_chars - 1 chars and appends the ellipsis glyph. + assert_eq!(c.summary(4), "abc…"); +} + +#[test] +fn summary_collapses_whitespace_into_single_line() { + let c = comment("first line\n\nsecond line\tthird", None); + assert_eq!(c.summary(80), "first line second line third"); +} + +#[test] +fn summary_falls_back_to_title_when_content_is_empty() { + // Empty (whitespace-only) content and no file/line -> falls back to head_title. + let c = comment(" \n ", Some("PR summary")); + assert_eq!(c.summary(80), "PR summary"); + + // No head title either -> generic title. + let c = comment("", None); + assert_eq!(c.summary(80), "Review Comment"); +} diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 01ef5679f0..1fb543b75a 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -3175,6 +3175,12 @@ impl AIConversation { self.todo_lists.last() } + /// Returns the current code review state for this conversation, if any + /// comments have ever been sent to the agent. + pub fn code_review(&self) -> Option<&CodeReview> { + self.code_review.as_ref() + } + pub fn active_todo(&self) -> Option<&AIAgentTodo> { self.active_todo_list() .and_then(|todo_list| todo_list.in_progress_item()) diff --git a/app/src/ai/blocklist/prompt.rs b/app/src/ai/blocklist/prompt.rs index 842f7f4516..9cf9955c0e 100644 --- a/app/src/ai/blocklist/prompt.rs +++ b/app/src/ai/blocklist/prompt.rs @@ -10,6 +10,7 @@ use crate::util::color::coloru_with_opacity; use crate::view_components::action_button::{ActionButtonTheme, NakedTheme}; use crate::Appearance; +pub mod code_review_comments; pub mod plan_and_todo_list; pub mod prompt_alert; diff --git a/app/src/ai/blocklist/prompt/code_review_comments.rs b/app/src/ai/blocklist/prompt/code_review_comments.rs new file mode 100644 index 0000000000..ce2b4fab0d --- /dev/null +++ b/app/src/ai/blocklist/prompt/code_review_comments.rs @@ -0,0 +1,633 @@ +//! The code review comments chip shown in the agent view input, plus its popup. +//! +//! This mirrors [`crate::ai::blocklist::prompt::plan_and_todo_list::PlanAndTodoListView`] +//! (the chip) and [`crate::ai::agent::todos::popup::AgentTodosPopupView`] (the popup), +//! but is driven by the active conversation's `CodeReview` state instead of its todo list. +//! +//! The chip shows `resolved / total` review-comment counts (where "resolved" means the +//! agent has addressed the comment, i.e. it moved from `pending_comments` to +//! `addressed_comments`). Clicking it opens a popup that lists each comment as a one-line +//! summary that can be expanded to show the full comment body. + +use std::collections::HashSet; +use std::sync::Arc; + +use pathfinder_color::ColorU; +use pathfinder_geometry::vector::vec2f; +use warp_core::features::FeatureFlag; +use warp_core::ui::appearance::Appearance; +use warp_core::ui::theme::color::internal_colors; +use warp_core::ui::theme::Fill; +use warp_core::ui::Icon; +use warpui::elements::{ + Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, + Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, DropShadow, Empty, + EventHandler, Expanded, Flex, Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, + ParentAnchor, ParentElement, ParentOffsetBounds, Radius, SavePosition, ScrollbarWidth, + Shrinkable, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO, +}; +use warpui::fonts::{Properties, Weight}; +use warpui::keymap::FixedBinding; +use warpui::platform::Cursor; +use warpui::text_layout::ClipConfig; +use warpui::ui_components::components::UiComponent; +use warpui::{ + AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, TypedActionView, + View, ViewContext, ViewHandle, +}; + +use crate::ai::agent::comment::ReviewComment; +use crate::ai::agent::icons::addressed_comment_icon; +use crate::ai::blocklist::{BlocklistAIContextEvent, BlocklistAIContextModel}; +use crate::code_review::comments::CommentId; +use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; +use crate::ui_components::blended_colors; +use crate::BlocklistAIHistoryModel; + +const COMMENTS_BUTTON_SAVE_POSITION_ID: &str = "code_review_comments::comments_button"; + +/// Defensive upper bound on the length of a comment's one-line summary in the popup. +/// The row also clips with an ellipsis based on the available width, so this only guards +/// against laying out pathologically long single-line strings. +const COMMENT_SUMMARY_MAX_CHARS: usize = 120; + +pub fn init(app: &mut AppContext) { + use warpui::keymap::macros::*; + + app.register_fixed_bindings([FixedBinding::new( + "escape", + CodeReviewCommentsPopupAction::ClosePopup, + id!(CodeReviewCommentsPopupView::ui_name()), + )]); +} + +/// A context chip that shows the code review comments for the active conversation. +pub struct CodeReviewCommentsView { + context_model: ModelHandle, + menu_positioning_provider: Arc, + terminal_view_id: EntityId, + comments_button_mouse_state: MouseStateHandle, + popup: ViewHandle, + is_popup_open: bool, + is_in_agent_view: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CodeReviewCommentsAction { + TogglePopup, +} + +impl CodeReviewCommentsView { + pub fn new( + context_model: ModelHandle, + menu_positioning_provider: Arc, + terminal_view_id: EntityId, + is_in_agent_view: bool, + ctx: &mut ViewContext, + ) -> Self { + let popup = ctx.add_typed_action_view(|ctx| { + CodeReviewCommentsPopupView::new(terminal_view_id, context_model.clone(), ctx) + }); + ctx.subscribe_to_view(&popup, |me, _, event, ctx| match event { + CodeReviewCommentsPopupEvent::Close => { + me.is_popup_open = false; + ctx.notify(); + } + }); + + ctx.subscribe_to_model( + &BlocklistAIHistoryModel::handle(ctx), + |me, _, event, ctx| { + if event + .terminal_surface_id() + .is_some_and(|id| id != me.terminal_view_id) + { + return; + } + // Re-render on any conversation-level change so the counts stay in sync as + // comments are sent to (pending) and addressed by (resolved) the agent. + ctx.notify(); + }, + ); + + ctx.subscribe_to_model(&context_model, |_, _, event, ctx| { + if let BlocklistAIContextEvent::PendingQueryStateUpdated = event { + ctx.notify(); + } + }); + + Self { + context_model, + menu_positioning_provider, + terminal_view_id, + comments_button_mouse_state: Default::default(), + popup, + is_popup_open: false, + is_in_agent_view, + } + } + + pub fn should_render(&self, app: &AppContext) -> bool { + if !FeatureFlag::CodeReviewCommentsChip.is_enabled() { + return false; + } + self.context_model + .as_ref(app) + .selected_conversation(app) + .and_then(|conversation| conversation.code_review()) + .is_some_and(|code_review| { + !code_review.addressed_comments.is_empty() + || !code_review.pending_comments.is_empty() + }) + } + + fn render_comments_button( + &self, + resolved: usize, + total: usize, + icon_size: f32, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let primary_color = appearance.theme().surface_1(); + let comment_icon = Container::new( + ConstrainedBox::new( + Icon::MessageChatSquare + .to_warpui_icon(if self.is_in_agent_view { + appearance + .theme() + .sub_text_color(blended_colors::neutral_1(appearance.theme()).into()) + } else { + internal_colors::fg_overlay_7(appearance.theme()) + }) + .finish(), + ) + .with_height(icon_size) + .with_width(icon_size) + .finish(), + ) + .finish(); + + let chip_font_size = appearance.monospace_font_size() - 1.0; + let line_height_ratio = appearance.line_height_ratio(); + + let resolved_text = Text::new_inline( + format!("{resolved}"), + appearance.ui_font_family(), + chip_font_size, + ) + .with_color(blended_colors::text_main(appearance.theme(), primary_color)) + .with_line_height_ratio(line_height_ratio) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(); + + let slash_text = Text::new_inline("/", appearance.ui_font_family(), chip_font_size) + .with_color(appearance.theme().sub_text_color(primary_color).into()) + .with_line_height_ratio(line_height_ratio) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(); + + let total_text = Text::new_inline( + format!("{total}"), + appearance.ui_font_family(), + chip_font_size, + ) + .with_color(appearance.theme().sub_text_color(primary_color).into()) + .with_line_height_ratio(line_height_ratio) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(); + + let content = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(comment_icon) + .with_child(Container::new(resolved_text).with_margin_left(4.).finish()) + .with_child(Container::new(slash_text).with_margin_left(2.).finish()) + .with_child(Container::new(total_text).with_margin_left(2.).finish()) + .finish(); + + let button = Hoverable::new(self.comments_button_mouse_state.clone(), move |state| { + let background = if state.is_hovered() { + internal_colors::fg_overlay_2(appearance.theme()) + } else { + internal_colors::fg_overlay_1(appearance.theme()) + }; + + let container = Container::new(content) + .with_background(background) + .with_padding_left(6.) + .with_padding_right(6.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_border( + Border::all(1.0) + .with_border_fill(internal_colors::neutral_3(appearance.theme())), + ) + .with_padding_top(2.) + .with_padding_bottom(2.) + .finish(); + + if state.is_hovered() { + let mut stack = Stack::new().with_child(container); + let tooltip_element = appearance + .ui_builder() + .tool_tip("View code review comments".to_string()) + .build() + .finish(); + stack.add_positioned_overlay_child( + tooltip_element, + OffsetPositioning::offset_from_parent( + vec2f(0., -8.), + ParentOffsetBounds::WindowByPosition, + ParentAnchor::TopLeft, + ChildAnchor::BottomLeft, + ), + ); + stack.finish() + } else { + container + } + }) + .with_cursor(Cursor::PointingHand) + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(CodeReviewCommentsAction::TogglePopup); + }) + .finish(); + + let button = SavePosition::new(button, COMMENTS_BUTTON_SAVE_POSITION_ID).finish(); + + let mut button = Stack::new().with_child(button); + if self.is_popup_open { + let positioning = match self.menu_positioning_provider.menu_position(app) { + MenuPositioning::BelowInputBox => { + OffsetPositioning::offset_from_save_position_element( + COMMENTS_BUTTON_SAVE_POSITION_ID, + vec2f(0., 4.), + warpui::elements::PositionedElementOffsetBounds::WindowByPosition, + warpui::elements::PositionedElementAnchor::BottomLeft, + ChildAnchor::TopLeft, + ) + } + MenuPositioning::AboveInputBox => { + OffsetPositioning::offset_from_save_position_element( + COMMENTS_BUTTON_SAVE_POSITION_ID, + vec2f(0., -4.), + warpui::elements::PositionedElementOffsetBounds::WindowByPosition, + warpui::elements::PositionedElementAnchor::TopLeft, + ChildAnchor::BottomLeft, + ) + } + }; + button.add_positioned_overlay_child(ChildView::new(&self.popup).finish(), positioning); + } + + button.finish() + } +} + +impl Entity for CodeReviewCommentsView { + type Event = (); +} + +impl View for CodeReviewCommentsView { + fn ui_name() -> &'static str { + "CodeReviewCommentsView" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + + let Some(code_review) = self + .context_model + .as_ref(app) + .selected_conversation(app) + .and_then(|conversation| conversation.code_review()) + else { + return Empty::new().finish(); + }; + let resolved = code_review.addressed_comments.len(); + let total = resolved + code_review.pending_comments.len(); + if total == 0 || !FeatureFlag::CodeReviewCommentsChip.is_enabled() { + return Empty::new().finish(); + } + + let base_icon_size = app.font_cache().line_height( + appearance.monospace_font_size(), + DEFAULT_UI_LINE_HEIGHT_RATIO / 1.4, + ); + let text_line_height = app.font_cache().line_height( + appearance.monospace_font_size() - 1.0, + appearance.line_height_ratio(), + ); + let icon_size = (base_icon_size * 1.1).min(text_line_height); + + self.render_comments_button(resolved, total, icon_size, appearance, app) + } +} + +impl TypedActionView for CodeReviewCommentsView { + type Action = CodeReviewCommentsAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + CodeReviewCommentsAction::TogglePopup => { + self.is_popup_open = !self.is_popup_open; + if self.is_popup_open { + ctx.focus(&self.popup); + } + ctx.notify(); + } + } + } +} + +/// A popup that lists the active conversation's code review comments, each expandable +/// from a one-line summary to its full body. +pub struct CodeReviewCommentsPopupView { + terminal_view_id: EntityId, + ai_context_model: ModelHandle, + scroll_state: ClippedScrollStateHandle, + expanded: HashSet, +} + +#[derive(Debug, Clone, Copy)] +pub enum CodeReviewCommentsPopupAction { + ClosePopup, + ToggleExpanded(CommentId), +} + +pub enum CodeReviewCommentsPopupEvent { + Close, +} + +struct PopupStyles { + ui_font_family: warpui::fonts::FamilyId, + background: Fill, + main_text_color: ColorU, + sub_text_color: ColorU, + detail_font_size: f32, +} + +impl CodeReviewCommentsPopupView { + pub fn new( + terminal_view_id: EntityId, + ai_context_model: ModelHandle, + ctx: &mut ViewContext, + ) -> Self { + ctx.subscribe_to_model( + &BlocklistAIHistoryModel::handle(ctx), + move |me, _, event, ctx| { + if event + .terminal_surface_id() + .is_some_and(|id| id != me.terminal_view_id) + { + return; + } + ctx.notify(); + }, + ); + ctx.subscribe_to_model(&ai_context_model, |_, _, event, ctx| { + if let BlocklistAIContextEvent::PendingQueryStateUpdated = event { + ctx.notify(); + } + }); + Self { + terminal_view_id, + ai_context_model, + scroll_state: Default::default(), + expanded: HashSet::new(), + } + } + + fn close(&mut self, ctx: &mut ViewContext) { + ctx.emit(CodeReviewCommentsPopupEvent::Close); + } + + fn styles(&self, appearance: &Appearance) -> PopupStyles { + let theme = appearance.theme(); + let background = theme.surface_1(); + PopupStyles { + ui_font_family: appearance.ui_font_family(), + background, + main_text_color: blended_colors::text_main(theme, background), + sub_text_color: blended_colors::text_sub(theme, background), + detail_font_size: appearance.ui_font_size(), + } + } + + fn render_header( + &self, + appearance: &Appearance, + resolved: usize, + total: usize, + ) -> Box { + let styles = self.styles(appearance); + let theme = appearance.theme(); + let mut header = Text::new( + "Comments".to_string(), + appearance.header_font_family(), + styles.detail_font_size + 2., + ) + .with_color(styles.main_text_color) + .with_style(Properties::default().weight(Weight::Semibold)); + header.add_text_with_highlights( + format!(" {resolved}/{total}"), + theme.sub_text_color(theme.surface_1()).into(), + Properties::default().weight(Weight::Semibold), + ); + header.finish() + } + + fn render_row( + &self, + comment: &ReviewComment, + resolved: bool, + styles: &PopupStyles, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let comment_id = comment.id; + let is_expanded = self.expanded.contains(&comment_id); + + // Resolved comments get the same green check treatment as the blocklist's + // "addressed comment" chips; pending comments use a neutral chat icon. + let icon_element = if resolved { + addressed_comment_icon(appearance).finish() + } else { + Icon::MessageChatSquare + .to_warpui_icon(Fill::Solid(styles.main_text_color)) + .finish() + }; + + let mut header_row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Max); + header_row.add_child( + Container::new( + ConstrainedBox::new(icon_element) + .with_width(16.) + .with_height(16.) + .finish(), + ) + .with_margin_right(8.) + .finish(), + ); + header_row.add_child( + Expanded::new( + 1.0, + Text::new( + comment.summary(COMMENT_SUMMARY_MAX_CHARS), + styles.ui_font_family, + styles.detail_font_size, + ) + .with_color(if resolved { + styles.sub_text_color + } else { + styles.main_text_color + }) + // Keep each comment on a single line, clipping with an ellipsis when the + // text is wider than the popup rather than wrapping across multiple lines. + .soft_wrap(false) + .with_clip(ClipConfig::ellipsis()) + .finish(), + ) + .finish(), + ); + + let header_row = EventHandler::new(header_row.finish()) + .on_left_mouse_down(move |ctx, _, _| { + ctx.dispatch_typed_action(CodeReviewCommentsPopupAction::ToggleExpanded( + comment_id, + )); + DispatchEventResult::StopPropagation + }) + .finish(); + + let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + col.add_child(header_row); + if is_expanded { + col.add_child( + Container::new( + Text::new( + comment.content.clone(), + styles.ui_font_family, + styles.detail_font_size, + ) + .with_color(theme.sub_text_color(styles.background).into()) + .finish(), + ) + .with_margin_left(24.) + .with_margin_top(4.) + .finish(), + ); + } + col.finish() + } +} + +impl View for CodeReviewCommentsPopupView { + fn ui_name() -> &'static str { + "CodeReviewCommentsPopup" + } + + fn render(&self, app: &warpui::AppContext) -> Box { + let Some(code_review) = self + .ai_context_model + .as_ref(app) + .selected_conversation(app) + .and_then(|conversation| conversation.code_review()) + else { + // No empty state: the popup is only shown when there are comments. + return Empty::new().finish(); + }; + let resolved = code_review.addressed_comments.len(); + let total = resolved + code_review.pending_comments.len(); + if total == 0 { + return Empty::new().finish(); + } + + let appearance = Appearance::as_ref(app); + let styles = self.styles(appearance); + let theme = appearance.theme(); + + let mut list_col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(12.); + + // Resolved (addressed) comments first, then the still-pending ones. + for comment in &code_review.addressed_comments { + list_col.add_child(self.render_row(comment, true, &styles, appearance)); + } + for comment in &code_review.pending_comments { + list_col.add_child(self.render_row(comment, false, &styles, appearance)); + } + + let header = Container::new(self.render_header(appearance, resolved, total)) + .with_padding_top(16.) + .with_horizontal_padding(16.) + .with_padding_bottom(8.) + .finish(); + + let scrollable_body = ClippedScrollable::vertical( + self.scroll_state.clone(), + Container::new(list_col.finish()) + .with_horizontal_padding(16.) + .with_padding_bottom(16.) + .finish(), + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + warpui::elements::Fill::None, + ) + .with_overlayed_scrollbar() + .finish(); + + let panel_col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(header) + .with_child(Shrinkable::new(1.0, scrollable_body).finish()); + + Dismiss::new( + ConstrainedBox::new( + Container::new(panel_col.finish()) + .with_background(styles.background) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) + .with_border(Border::all(1.).with_border_fill(theme.outline())) + .with_drop_shadow(DropShadow::default()) + .finish(), + ) + .with_width(300.) + .with_max_height(420.) + .finish(), + ) + .prevent_interaction_with_other_elements() + .on_dismiss(|ctx, _app| { + ctx.dispatch_typed_action(CodeReviewCommentsPopupAction::ClosePopup); + }) + .finish() + } +} + +impl Entity for CodeReviewCommentsPopupView { + type Event = CodeReviewCommentsPopupEvent; +} + +impl TypedActionView for CodeReviewCommentsPopupView { + type Action = CodeReviewCommentsPopupAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + CodeReviewCommentsPopupAction::ClosePopup => { + self.close(ctx); + } + CodeReviewCommentsPopupAction::ToggleExpanded(comment_id) => { + if !self.expanded.remove(comment_id) { + self.expanded.insert(*comment_id); + } + ctx.notify(); + } + } + } +} + +#[cfg(test)] +#[path = "code_review_comments_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/prompt/code_review_comments_tests.rs b/app/src/ai/blocklist/prompt/code_review_comments_tests.rs new file mode 100644 index 0000000000..a24bad337c --- /dev/null +++ b/app/src/ai/blocklist/prompt/code_review_comments_tests.rs @@ -0,0 +1,27 @@ +use warp_core::features::FeatureFlag; + +use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; +use crate::context_chips::{agent_footer_available_chips, ContextChipKind}; + +#[test] +fn chip_is_available_but_not_default_when_flag_enabled() { + let _guard = FeatureFlag::CodeReviewCommentsChip.override_enabled(true); + + let is_crc = |item: &AgentToolbarItemKind| { + item.context_chip_kind() == Some(&ContextChipKind::CodeReviewComments) + }; + + // Offered in the footer configurator's available list... + assert!(agent_footer_available_chips().contains(&ContextChipKind::CodeReviewComments)); + assert!(AgentToolbarItemKind::all_available().iter().any(is_crc)); + + // ...but never in the default left/right footer selections. + assert!(!AgentToolbarItemKind::default_left().iter().any(is_crc)); + assert!(!AgentToolbarItemKind::default_right().iter().any(is_crc)); +} + +#[test] +fn chip_is_not_available_when_flag_disabled() { + let _guard = FeatureFlag::CodeReviewCommentsChip.override_enabled(false); + assert!(!agent_footer_available_chips().contains(&ContextChipKind::CodeReviewComments)); +} diff --git a/app/src/context_chips/display.rs b/app/src/context_chips/display.rs index 4d3091e385..35ce53733c 100644 --- a/app/src/context_chips/display.rs +++ b/app/src/context_chips/display.rs @@ -423,7 +423,10 @@ impl View for PromptDisplay { self.display_chips.iter().for_each(|display_chip| { let chip = display_chip.as_ref(app); // AgentPlanAndTodoList is only shown in the agent input footer - if matches!(chip.chip_kind(), ContextChipKind::AgentPlanAndTodoList) { + if matches!( + chip.chip_kind(), + ContextChipKind::AgentPlanAndTodoList | ContextChipKind::CodeReviewComments + ) { return; } if chip.should_render(app) { diff --git a/app/src/context_chips/display_chip.rs b/app/src/context_chips/display_chip.rs index 31ebafa6fe..322c4b9f78 100644 --- a/app/src/context_chips/display_chip.rs +++ b/app/src/context_chips/display_chip.rs @@ -32,6 +32,7 @@ use super::{ ChipValue, ContextChipKind, }; use crate::ai::blocklist::agent_view::AgentViewController; +use crate::ai::blocklist::prompt::code_review_comments::CodeReviewCommentsView; use crate::ai::blocklist::prompt::plan_and_todo_list::{PlanAndTodoListEvent, PlanAndTodoListView}; use crate::ai::blocklist::{BlocklistAIContextModel, BlocklistAIInputModel}; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; @@ -632,6 +633,9 @@ pub enum DisplayChipKind { AgentPlanAndTodoList { plan_and_todo_list: ViewHandle, }, + CodeReviewComments { + code_review_comments: ViewHandle, + }, GitBranch { menu_open: bool, menu: ViewHandle, @@ -659,7 +663,8 @@ impl DisplayChipKind { | DisplayChipKind::Subshell | DisplayChipKind::VirtualEnvironment | DisplayChipKind::CondaEnvironment - | DisplayChipKind::AgentPlanAndTodoList { .. } => false, + | DisplayChipKind::AgentPlanAndTodoList { .. } + | DisplayChipKind::CodeReviewComments { .. } => false, } } } @@ -861,6 +866,23 @@ impl DisplayChip { DisplayChipKind::AgentPlanAndTodoList { plan_and_todo_list } } + ContextChipKind::CodeReviewComments => { + let context_model = config.ai_context_model.clone(); + let view_id = config.terminal_view_id; + let code_review_comments = ctx.add_typed_action_view(|ctx| { + CodeReviewCommentsView::new( + context_model, + config.menu_positioning_provider.clone(), + view_id, + is_in_agent_view, + ctx, + ) + }); + + DisplayChipKind::CodeReviewComments { + code_review_comments, + } + } ContextChipKind::ShellGitBranch => { // Convert git branch strings to GitBranch items let git_branch_items: Vec = chip_result @@ -1254,6 +1276,7 @@ impl DisplayChip { | DisplayChipKind::CondaEnvironment | DisplayChipKind::NodeVersion { .. } | DisplayChipKind::AgentPlanAndTodoList { .. } + | DisplayChipKind::CodeReviewComments { .. } | DisplayChipKind::GithubPullRequest => {} } false @@ -1355,6 +1378,9 @@ impl DisplayChip { DisplayChipKind::AgentPlanAndTodoList { plan_and_todo_list } => { plan_and_todo_list.as_ref(app).should_render(app) } + DisplayChipKind::CodeReviewComments { + code_review_comments, + } => code_review_comments.as_ref(app).should_render(app), _ => true, } } @@ -1979,6 +2005,9 @@ impl DisplayChip { DisplayChipKind::AgentPlanAndTodoList { plan_and_todo_list } => { Some(ChildView::new(plan_and_todo_list).finish()) } + DisplayChipKind::CodeReviewComments { + code_review_comments, + } => Some(ChildView::new(code_review_comments).finish()), DisplayChipKind::GitBranch { menu_open, menu } => { Some(self.git_branch_chip(*menu_open, menu, app)) } @@ -2101,6 +2130,7 @@ impl TypedActionView for DisplayChip { | DisplayChipKind::VirtualEnvironment | DisplayChipKind::CondaEnvironment | DisplayChipKind::AgentPlanAndTodoList { .. } + | DisplayChipKind::CodeReviewComments { .. } | DisplayChipKind::Text | DisplayChipKind::GithubPullRequest | DisplayChipKind::GitBranchStatus { .. } diff --git a/app/src/context_chips/mod.rs b/app/src/context_chips/mod.rs index 92209052ee..057dbfa9d2 100644 --- a/app/src/context_chips/mod.rs +++ b/app/src/context_chips/mod.rs @@ -199,6 +199,9 @@ pub enum ContextChipKind { Subshell, /// A chip that shows the plan and todo list for the current conversation. AgentPlanAndTodoList, + /// A chip that shows code review comment resolution progress (resolved / + /// total) for the current conversation, and lists the comments in a popup. + CodeReviewComments, } impl ContextChipKind { @@ -361,12 +364,17 @@ impl ContextChipKind { |_| Some(ChipValue::Text(String::new())), RefreshConfig::OnDemandOnly, )), + Self::CodeReviewComments => Some(ContextChip::builtin( + "Code Review Comments", + |_| Some(ChipValue::Text(String::new())), + RefreshConfig::OnDemandOnly, + )), } } /// Whether the context chip has a copyable value. pub fn is_copyable(&self) -> bool { - !matches!(self, Self::AgentPlanAndTodoList) + !matches!(self, Self::AgentPlanAndTodoList | Self::CodeReviewComments) } /// Returns a generator to be used for the first fetch of @@ -417,6 +425,7 @@ impl ContextChipKind { Self::Ssh => ChipValue::Text("alice@127.0.0.1".to_string()), Self::Subshell => ChipValue::Text("bash".to_string()), Self::AgentPlanAndTodoList => ChipValue::Text("Plan and Todo List".to_string()), + Self::CodeReviewComments => ChipValue::Text("Code Review Comments".to_string()), } } @@ -450,6 +459,7 @@ impl ContextChipKind { Self::Ssh => prompt_colors.input_prompt_ssh, Self::Subshell => prompt_colors.input_prompt_subshell, Self::AgentPlanAndTodoList => prompt_colors.input_prompt_agent_mode_hint, + Self::CodeReviewComments => prompt_colors.input_prompt_agent_mode_hint, Self::Custom { .. } => ColorU::new(255, 255, 255, 255), }; @@ -543,6 +553,7 @@ impl ContextChipKind { Self::GithubPullRequest => Some(Icon::Github), Self::KubernetesContext => Some(Icon::Globe), Self::AgentPlanAndTodoList => Some(Icon::CheckSkinny), + Self::CodeReviewComments => Some(Icon::MessageChatSquare), Self::Custom { .. } => None, } } @@ -552,6 +563,11 @@ impl ContextChipKind { pub fn agent_footer_available_chips() -> Vec { let mut chips = available_chips(); chips.push(ContextChipKind::AgentPlanAndTodoList); + // Opt-in chip: offered in the footer configurator only when the flag is on, + // and never added to the default left/right selections. + if FeatureFlag::CodeReviewCommentsChip.is_enabled() { + chips.push(ContextChipKind::CodeReviewComments); + } chips } diff --git a/app/src/features.rs b/app/src/features.rs index 871e63fdf7..7d8801b322 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -253,6 +253,8 @@ fn enabled_features() -> HashSet { FeatureFlag::CodeModeChip, #[cfg(feature = "github_pr_prompt_chip")] FeatureFlag::GithubPrPromptChip, + #[cfg(feature = "code_review_comments_chip")] + FeatureFlag::CodeReviewCommentsChip, #[cfg(feature = "create_project_flow")] FeatureFlag::CreateProjectFlow, #[cfg(feature = "vim_code_editor")] diff --git a/app/src/lib.rs b/app/src/lib.rs index d041d6de51..db48d7a802 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1852,6 +1852,7 @@ pub(crate) fn initialize_app( context_chips::node_version_popup::init(ctx); env_vars::view::env_var_collection::init(ctx); ai::agent::todos::popup::init(ctx); + ai::blocklist::prompt::code_review_comments::init(ctx); terminal::view::init_environment::mode_selector::init(ctx); coding_entrypoints::project_buttons::init(ctx); if FeatureFlag::CodeReviewSaveChanges.is_enabled() { diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 31d0e3f963..535d7a760d 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -901,6 +901,11 @@ pub enum FeatureFlag { /// Enables the `--runner` flag on `run-cloud`, which overrides an agent's /// compute (docker image, instance shape, setup commands) by runner ID. CloudRunners, + + /// A context chip in the agent view input that shows code review comment + /// resolution progress (resolved / total) and lists the comments in a popup. + /// Opt-in: not shown in the footer by default. + CodeReviewCommentsChip, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -971,6 +976,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::ContextWindowUsageBreakdown, FeatureFlag::CloudRunners, FeatureFlag::WaitForEventsParentRegistration, + FeatureFlag::CodeReviewCommentsChip, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp).