From 519408e751740852effbcefa5bf395452b025df8 Mon Sep 17 00:00:00 2001 From: Oz Date: Tue, 30 Jun 2026 20:52:35 +0000 Subject: [PATCH 1/4] Add code review comments chip to the agent view input Adds an opt-in context chip to the agent-view input footer that shows code-review comment resolution progress (resolved / total) for the active conversation, mirroring the existing todo chip. Clicking it opens a popup listing each comment as a one-line summary that expands to the full body. "Resolved" maps to the conversation's CodeReview state: total = pending_comments + addressed_comments, resolved = addressed_comments (comments the agent has addressed after they were sent from the code review panel). The chip is gated behind the CodeReviewCommentsChip feature flag and is offered in the agent toolbelt configurator's available list but is NOT in the default footer selection. Co-Authored-By: Warp --- app/Cargo.toml | 1 + app/src/ai/agent/conversation.rs | 6 + app/src/ai/blocklist/prompt.rs | 1 + .../blocklist/prompt/code_review_comments.rs | 625 ++++++++++++++++++ .../prompt/code_review_comments_tests.rs | 55 ++ app/src/context_chips/display.rs | 5 +- app/src/context_chips/display_chip.rs | 32 +- app/src/context_chips/mod.rs | 18 +- app/src/features.rs | 2 + app/src/lib.rs | 1 + crates/warp_features/src/lib.rs | 6 + 11 files changed, 749 insertions(+), 3 deletions(-) create mode 100644 app/src/ai/blocklist/prompt/code_review_comments.rs create mode 100644 app/src/ai/blocklist/prompt/code_review_comments_tests.rs 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/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..c51b8c99ef --- /dev/null +++ b/app/src/ai/blocklist/prompt/code_review_comments.rs @@ -0,0 +1,625 @@ +//! 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::ui_components::components::UiComponent; +use warpui::{ + AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, TypedActionView, + View, ViewContext, ViewHandle, +}; + +use crate::ai::agent::comment::{CodeReview, ReviewComment}; +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"; + +/// Reads the active conversation's code review state, if any. +fn selected_code_review<'a>( + context_model: &ModelHandle, + app: &'a AppContext, +) -> Option<&'a CodeReview> { + context_model + .as_ref(app) + .selected_conversation(app) + .and_then(|conversation| conversation.code_review()) +} + +/// Returns `(resolved, total)` review comment counts for the active conversation. +fn resolved_and_total(code_review: &CodeReview) -> (usize, usize) { + let resolved = code_review.addressed_comments.len(); + let total = resolved + code_review.pending_comments.len(); + (resolved, total) +} + +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; + } + selected_code_review(&self.context_model, app) + .map(resolved_and_total) + .is_some_and(|(_, total)| total > 0) + } + + 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((resolved, total)) = + selected_code_review(&self.context_model, app).map(resolved_and_total) + else { + return Empty::new().finish(); + }; + 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); + + let icon = if resolved { + Icon::AddressedComment + } else { + Icon::MessageChatSquare + }; + let icon_color = if resolved { + styles.sub_text_color + } else { + styles.main_text_color + }; + + 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.to_warpui_icon(Fill::Solid(icon_color)).finish()) + .with_width(16.) + .with_height(16.) + .finish(), + ) + .with_margin_right(8.) + .finish(), + ); + header_row.add_child( + Expanded::new( + 1.0, + Text::new( + comment.title(), + styles.ui_font_family, + styles.detail_font_size, + ) + .with_color(if resolved { + styles.sub_text_color + } else { + styles.main_text_color + }) + .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) = selected_code_review(&self.ai_context_model, app) else { + // No empty state: the popup is only shown when there are comments. + return Empty::new().finish(); + }; + let (resolved, total) = resolved_and_total(code_review); + 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..995bb2125a --- /dev/null +++ b/app/src/ai/blocklist/prompt/code_review_comments_tests.rs @@ -0,0 +1,55 @@ +use warp_core::features::FeatureFlag; + +use super::resolved_and_total; +use crate::ai::agent::comment::{CodeReview, ReviewComment, ReviewDiff}; +use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; +use crate::code_review::comments::CommentId; +use crate::context_chips::{agent_footer_available_chips, ContextChipKind}; + +fn comment(content: &str) -> ReviewComment { + ReviewComment { + id: CommentId::default(), + content: content.to_string(), + diff: ReviewDiff { + file_path: None, + line_number: None, + }, + head_title: None, + } +} + +#[test] +fn resolved_and_total_counts_addressed_then_total() { + let code_review = CodeReview { + pending_comments: vec![comment("a"), comment("b")], + addressed_comments: vec![comment("c")], + }; + // resolved == addressed, total == addressed + pending. + assert_eq!(resolved_and_total(&code_review), (1, 3)); + + let empty = CodeReview::default(); + assert_eq!(resolved_and_total(&empty), (0, 0)); +} + +#[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). From 551c1f21efa41612bd784f3ad5b6073511b1fc0b Mon Sep 17 00:00:00 2001 From: Oz Date: Wed, 1 Jul 2026 19:14:23 +0000 Subject: [PATCH 2/4] Address review: inline code-review-comments helper fns Inline the `selected_code_review` and `resolved_and_total` helpers into their call sites in the chip and popup views, per review feedback. Drops the now-obsolete counts unit test (the logic is inline); keeps the available-but-not-default coverage. Co-Authored-By: Warp --- .../blocklist/prompt/code_review_comments.rs | 52 +++++++++---------- .../prompt/code_review_comments_tests.rs | 28 ---------- 2 files changed, 25 insertions(+), 55 deletions(-) diff --git a/app/src/ai/blocklist/prompt/code_review_comments.rs b/app/src/ai/blocklist/prompt/code_review_comments.rs index c51b8c99ef..ccf52a56c1 100644 --- a/app/src/ai/blocklist/prompt/code_review_comments.rs +++ b/app/src/ai/blocklist/prompt/code_review_comments.rs @@ -2,7 +2,7 @@ //! //! 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. +//! 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 @@ -35,7 +35,7 @@ use warpui::{ View, ViewContext, ViewHandle, }; -use crate::ai::agent::comment::{CodeReview, ReviewComment}; +use crate::ai::agent::comment::ReviewComment; use crate::ai::blocklist::{BlocklistAIContextEvent, BlocklistAIContextModel}; use crate::code_review::comments::CommentId; use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; @@ -44,24 +44,6 @@ use crate::BlocklistAIHistoryModel; const COMMENTS_BUTTON_SAVE_POSITION_ID: &str = "code_review_comments::comments_button"; -/// Reads the active conversation's code review state, if any. -fn selected_code_review<'a>( - context_model: &ModelHandle, - app: &'a AppContext, -) -> Option<&'a CodeReview> { - context_model - .as_ref(app) - .selected_conversation(app) - .and_then(|conversation| conversation.code_review()) -} - -/// Returns `(resolved, total)` review comment counts for the active conversation. -fn resolved_and_total(code_review: &CodeReview) -> (usize, usize) { - let resolved = code_review.addressed_comments.len(); - let total = resolved + code_review.pending_comments.len(); - (resolved, total) -} - pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; @@ -142,9 +124,14 @@ impl CodeReviewCommentsView { if !FeatureFlag::CodeReviewCommentsChip.is_enabled() { return false; } - selected_code_review(&self.context_model, app) - .map(resolved_and_total) - .is_some_and(|(_, total)| total > 0) + 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( @@ -301,11 +288,16 @@ impl View for CodeReviewCommentsView { fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); - let Some((resolved, total)) = - selected_code_review(&self.context_model, app).map(resolved_and_total) + 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(); } @@ -527,11 +519,17 @@ impl View for CodeReviewCommentsPopupView { } fn render(&self, app: &warpui::AppContext) -> Box { - let Some(code_review) = selected_code_review(&self.ai_context_model, app) else { + 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, total) = resolved_and_total(code_review); + let resolved = code_review.addressed_comments.len(); + let total = resolved + code_review.pending_comments.len(); if total == 0 { return Empty::new().finish(); } diff --git a/app/src/ai/blocklist/prompt/code_review_comments_tests.rs b/app/src/ai/blocklist/prompt/code_review_comments_tests.rs index 995bb2125a..a24bad337c 100644 --- a/app/src/ai/blocklist/prompt/code_review_comments_tests.rs +++ b/app/src/ai/blocklist/prompt/code_review_comments_tests.rs @@ -1,36 +1,8 @@ use warp_core::features::FeatureFlag; -use super::resolved_and_total; -use crate::ai::agent::comment::{CodeReview, ReviewComment, ReviewDiff}; use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; -use crate::code_review::comments::CommentId; use crate::context_chips::{agent_footer_available_chips, ContextChipKind}; -fn comment(content: &str) -> ReviewComment { - ReviewComment { - id: CommentId::default(), - content: content.to_string(), - diff: ReviewDiff { - file_path: None, - line_number: None, - }, - head_title: None, - } -} - -#[test] -fn resolved_and_total_counts_addressed_then_total() { - let code_review = CodeReview { - pending_comments: vec![comment("a"), comment("b")], - addressed_comments: vec![comment("c")], - }; - // resolved == addressed, total == addressed + pending. - assert_eq!(resolved_and_total(&code_review), (1, 3)); - - let empty = CodeReview::default(); - assert_eq!(resolved_and_total(&empty), (0, 0)); -} - #[test] fn chip_is_available_but_not_default_when_flag_enabled() { let _guard = FeatureFlag::CodeReviewCommentsChip.override_enabled(true); From 21ccf0f672571a27dc170755245a6ff29bc481ea Mon Sep 17 00:00:00 2001 From: Oz Date: Wed, 1 Jul 2026 20:05:52 +0000 Subject: [PATCH 3/4] Show comment text (truncated) in code review comments popup The popup rows previously showed each comment's file/line location (`ReviewComment::title()`). Instead, show the actual comment text, collapsing internal whitespace/newlines into a single line and clipping with an ellipsis based on the available width (with a defensive char cap). Adds `ReviewComment::summary()` plus unit tests covering truncation, whitespace collapsing, and the empty-content fallback to `title()`. Co-Authored-By: Oz --- app/src/ai/agent/comment.rs | 22 ++++++++++ app/src/ai/agent/comment_tests.rs | 44 +++++++++++++++++++ .../blocklist/prompt/code_review_comments.rs | 12 ++++- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 app/src/ai/agent/comment_tests.rs 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/blocklist/prompt/code_review_comments.rs b/app/src/ai/blocklist/prompt/code_review_comments.rs index ccf52a56c1..576795f769 100644 --- a/app/src/ai/blocklist/prompt/code_review_comments.rs +++ b/app/src/ai/blocklist/prompt/code_review_comments.rs @@ -29,6 +29,7 @@ use warpui::elements::{ 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, @@ -44,6 +45,11 @@ 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::*; @@ -468,7 +474,7 @@ impl CodeReviewCommentsPopupView { Expanded::new( 1.0, Text::new( - comment.title(), + comment.summary(COMMENT_SUMMARY_MAX_CHARS), styles.ui_font_family, styles.detail_font_size, ) @@ -477,6 +483,10 @@ impl CodeReviewCommentsPopupView { } 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(), From 2798bdc043442dbd1bd7bffdab20c3c738673116 Mon Sep 17 00:00:00 2001 From: Oz Date: Wed, 1 Jul 2026 20:33:08 +0000 Subject: [PATCH 4/4] Use green check icon for resolved code review comments in popup Match the blocklist's "addressed comment" styling: resolved rows in the code review comments popup now use the shared green `addressed_comment_icon` instead of a muted checkmark, so resolution reads at a glance. Pending rows keep the neutral chat-bubble icon. Co-Authored-By: Oz --- .../ai/blocklist/prompt/code_review_comments.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/ai/blocklist/prompt/code_review_comments.rs b/app/src/ai/blocklist/prompt/code_review_comments.rs index 576795f769..ce2b4fab0d 100644 --- a/app/src/ai/blocklist/prompt/code_review_comments.rs +++ b/app/src/ai/blocklist/prompt/code_review_comments.rs @@ -37,6 +37,7 @@ use warpui::{ }; 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}; @@ -446,15 +447,14 @@ impl CodeReviewCommentsPopupView { let comment_id = comment.id; let is_expanded = self.expanded.contains(&comment_id); - let icon = if resolved { - Icon::AddressedComment + // 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 - }; - let icon_color = if resolved { - styles.sub_text_color - } else { - styles.main_text_color + .to_warpui_icon(Fill::Solid(styles.main_text_color)) + .finish() }; let mut header_row = Flex::row() @@ -462,7 +462,7 @@ impl CodeReviewCommentsPopupView { .with_main_axis_size(MainAxisSize::Max); header_row.add_child( Container::new( - ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish()) + ConstrainedBox::new(icon_element) .with_width(16.) .with_height(16.) .finish(),