From 6095c117937b86a1ab9c532af66f319e119a33a5 Mon Sep 17 00:00:00 2001 From: Kevin Yang Date: Mon, 22 Jun 2026 17:39:30 +0000 Subject: [PATCH] Planning document commenting --- app/Cargo.toml | 1 + app/src/ai/ai_document_view.rs | 283 +++++++++- app/src/ai/document/mod.rs | 2 + app/src/ai/document/plan_comment_list_view.rs | 518 ++++++++++++++++++ app/src/ai/document/plan_comments.rs | 207 +++++++ app/src/ai/document/plan_comments_tests.rs | 105 ++++ app/src/code/editor/comment_editor.rs | 1 + app/src/code_review/comment_rendering.rs | 6 +- app/src/features.rs | 2 + app/src/notebooks/editor/model.rs | 28 + app/src/notebooks/editor/omnibar.rs | 31 +- app/src/notebooks/editor/view.rs | 16 +- app/src/notebooks/file/mod.rs | 3 +- app/src/notebooks/notebook.rs | 3 +- crates/warp_features/src/lib.rs | 5 + 15 files changed, 1200 insertions(+), 11 deletions(-) create mode 100644 app/src/ai/document/plan_comment_list_view.rs create mode 100644 app/src/ai/document/plan_comments.rs create mode 100644 app/src/ai/document/plan_comments_tests.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index 7901258dc7..4c6c613cfc 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -697,6 +697,7 @@ editable_markdown_mermaid = [] default_adeberry_theme = [] drag_tabs_to_windows = [] embedded_code_review_comments = [] +plan_comments = [] agent_management_view = [] agent_management_details_view = [] interactive_conversation_management_view = [] diff --git a/app/src/ai/ai_document_view.rs b/app/src/ai/ai_document_view.rs index 8dced4759c..b5924ec97a 100644 --- a/app/src/ai/ai_document_view.rs +++ b/app/src/ai/ai_document_view.rs @@ -6,6 +6,8 @@ use pathfinder_geometry::vector::vec2f; use warp_core::ui::icons; use warp_core::ui::icons::ICON_DIMENSIONS; use warp_core::ui::theme::Fill as ThemeFill; +use warp_editor::content::anchor::Anchor; +use warp_editor::model::CoreEditorModel; use warpui::clipboard::ClipboardContent; use warpui::elements::{ ChildAnchor, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable, @@ -28,11 +30,19 @@ use crate::ai::document::ai_document_model::{ AIDocumentUpdateSource, AIDocumentUserEditStatus, AIDocumentVersion, }; use crate::ai::document::orchestration_config_block::OrchestrationConfigBlockView; +use crate::ai::document::plan_comment_list_view::{PlanCommentListEvent, PlanCommentListView}; +use crate::ai::document::plan_comments::{ + build_plan_comment_prompt, PlanComment, PlanCommentBatch, PlanCommentId, PlanCommentTarget, +}; use crate::appearance::Appearance; +use crate::code::editor::comment_editor::{CommentEditor, CommentEditorEvent}; +use crate::code::editor::EditorCommentsModel; +use crate::code_review::comments::{CommentId, CommentOrigin}; use crate::drive::items::WarpDriveItemId; use crate::drive::sharing::ShareableObject; use crate::drive::CloudObjectTypeAndId; use crate::editor::InteractionState; +use crate::features::FeatureFlag; use crate::menu::{Menu, MenuItem, MenuItemFields}; use crate::notebooks::editor::model::NotebooksEditorModel; use crate::notebooks::editor::rich_text_styles; @@ -105,6 +115,7 @@ pub enum AIDocumentAction { CopyPlanId, ShowInWarpDrive, AttachToActiveSession, + AddComment, } #[derive(Debug, Clone)] @@ -161,6 +172,16 @@ pub struct AIDocumentView { view_position_id: String, version_button: ViewHandle, orchestration_config_block: Option>, + /// Plan comments model + bottom panel (gated by `FeatureFlag::PlanComments`). + comment_model: ModelHandle, + comment_list_view: ViewHandle, + /// The inline comment composer, when open. + comment_composer: Option>, + /// Captured selection anchors (head, tail, quoted text) for a new range comment. + pending_comment_anchors: Option<(Anchor, Anchor, String)>, + /// The comment currently being edited, if any. + editing_comment_id: Option, + add_comment_button: ViewHandle, } impl AIDocumentView { @@ -341,7 +362,10 @@ impl AIDocumentView { view_position_id.clone(), initial_editor_model.clone(), links.clone(), - RichTextEditorConfig::default(), + RichTextEditorConfig { + comments_enabled: FeatureFlag::PlanComments.is_enabled(), + ..Default::default() + }, ctx, ) }); @@ -452,6 +476,26 @@ impl AIDocumentView { }) }); + let comment_model = ctx.add_model(|_| PlanCommentBatch::default()); + let comment_list_view = ctx.add_typed_action_view({ + let comment_model = comment_model.clone(); + move |ctx| PlanCommentListView::new(comment_model, ctx) + }); + ctx.subscribe_to_view(&comment_list_view, |me, _, event, ctx| { + me.handle_comment_list_event(event, ctx); + }); + let add_comment_button = ctx.add_typed_action_view(|_ctx| { + ActionButton::new("Comment", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action( + PaneHeaderAction::::CustomAction( + AIDocumentAction::AddComment, + ), + ); + }) + }); + let mut me = Self { document_id, document_version, @@ -470,6 +514,12 @@ impl AIDocumentView { view_position_id, version_button, orchestration_config_block, + comment_model, + comment_list_view, + comment_composer: None, + pending_comment_anchors: None, + editing_comment_id: None, + add_comment_button, }; // Force update the editor view based on the initial document version me.refresh(ctx); @@ -792,6 +842,13 @@ impl AIDocumentView { if let Some(sharing) = header_ctx.sharing_controls(app, None, None) { right_row.add_child(sharing); } + if FeatureFlag::PlanComments.is_enabled() { + right_row.add_child( + Container::new(ChildView::new(&self.add_comment_button).finish()) + .with_margin_right(8.) + .finish(), + ); + } if let Some(header_buttons) = self.render_header_buttons(app) { right_row.add_child(header_buttons); } @@ -840,7 +897,10 @@ impl AIDocumentView { view_position_id.clone(), editor_model.clone(), links, - RichTextEditorConfig::default(), + RichTextEditorConfig { + comments_enabled: FeatureFlag::PlanComments.is_enabled(), + ..Default::default() + }, ctx, ); editor.set_interaction_state( @@ -978,6 +1038,9 @@ impl AIDocumentView { }); } } + EditorViewEvent::AddComment => { + self.add_comment(ctx); + } _ => (), } } @@ -1055,6 +1118,207 @@ impl AIDocumentView { fn export(&self, _ctx: &mut ViewContext) { // No-op for WASM target } + + /// Opens the comment composer to attach a comment to the current selection (or the whole plan + /// if there is no selection). + fn add_comment(&mut self, ctx: &mut ViewContext) { + let editor_model = self.editor.as_ref(ctx).model().clone(); + self.pending_comment_anchors = + editor_model.update(ctx, |model, ctx| model.create_selection_anchors(ctx)); + self.editing_comment_id = None; + self.open_comment_composer(None, ctx); + } + + fn start_editing_comment(&mut self, id: PlanCommentId, ctx: &mut ViewContext) { + let Some(body) = self + .comment_model + .read(ctx, |batch, _| batch.get(id).map(|c| c.body.clone())) + else { + return; + }; + self.editing_comment_id = Some(id); + self.pending_comment_anchors = None; + self.open_comment_composer(Some(body), ctx); + } + + fn open_comment_composer(&mut self, prefill: Option, ctx: &mut ViewContext) { + let comment_model = ctx.add_model(EditorCommentsModel::new); + let composer = ctx.add_typed_action_view(move |ctx| CommentEditor::new(ctx, comment_model)); + + if let Some(text) = &prefill { + composer.update(ctx, |editor, ctx| { + editor.reopen_saved_comment( + &CommentId::new(), + None, + text, + &CommentOrigin::Native, + ctx, + ); + }); + } + + ctx.subscribe_to_view(&composer, |me, _, event, ctx| { + me.handle_comment_composer_event(event, ctx); + }); + + ctx.focus(&composer); + self.comment_composer = Some(composer); + ctx.notify(); + } + + fn handle_comment_composer_event( + &mut self, + event: &CommentEditorEvent, + ctx: &mut ViewContext, + ) { + match event { + CommentEditorEvent::CommentSaved { comment_text, .. } => { + self.save_plan_comment(comment_text.clone(), ctx); + } + CommentEditorEvent::DeleteComment { .. } => { + if let Some(id) = self.editing_comment_id.take() { + self.comment_model + .update(ctx, |batch, ctx| batch.delete_comment(id, ctx)); + } + } + CommentEditorEvent::CloseEditor => { + self.comment_composer = None; + self.pending_comment_anchors = None; + self.editing_comment_id = None; + ctx.notify(); + } + CommentEditorEvent::ContentChanged => {} + } + } + + fn save_plan_comment(&mut self, body: String, ctx: &mut ViewContext) { + if let Some(id) = self.editing_comment_id { + if let Some(mut comment) = self + .comment_model + .read(ctx, |batch, _| batch.get(id).cloned()) + { + comment.body = body; + comment.last_update_time = chrono::Local::now(); + self.comment_model + .update(ctx, |batch, ctx| batch.upsert_comment(comment, ctx)); + } + } else { + let comment = match self.pending_comment_anchors.take() { + Some((head, tail, quoted_text)) => { + PlanComment::new_range(body, head, tail, quoted_text) + } + None => PlanComment::new_general(body), + }; + self.comment_model + .update(ctx, |batch, ctx| batch.upsert_comment(comment, ctx)); + } + self.comment_list_view + .update(ctx, |list, ctx| list.expand(ctx)); + } + + fn handle_comment_list_event( + &mut self, + event: &PlanCommentListEvent, + ctx: &mut ViewContext, + ) { + match event { + PlanCommentListEvent::Submitted => self.submit_comments(ctx), + PlanCommentListEvent::Cancelled => { + self.comment_model + .update(ctx, |batch, ctx| batch.clear_all(ctx)); + } + PlanCommentListEvent::DeleteComment(id) => { + self.comment_model + .update(ctx, |batch, ctx| batch.delete_comment(*id, ctx)); + } + PlanCommentListEvent::EditComment(id) => self.start_editing_comment(*id, ctx), + } + } + + /// Submits the active plan comments to the agent by sending a formatted query into the plan's + /// conversation, with the latest plan attached as context. + fn submit_comments(&mut self, ctx: &mut ViewContext) { + let Some(terminal_view) = self.original_terminal_view.clone() else { + log::warn!("Cannot submit plan comments: no terminal view associated"); + return; + }; + let Some(conversation_id) = + AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(&self.document_id) + else { + log::warn!("Cannot submit plan comments: no conversation ID for document"); + return; + }; + + // Refresh outdated flags by resolving each range comment's anchors against the editor. + let selection_model = self + .editor + .as_ref(ctx) + .model() + .as_ref(ctx) + .buffer_selection_model() + .clone(); + let outdated_updates = self.comment_model.read(ctx, |batch, read_ctx| { + batch + .comments() + .iter() + .map(|comment| { + let outdated = + matches!(comment.target, PlanCommentTarget::DocumentRange { .. }) + && comment + .resolve_range(selection_model.as_ref(read_ctx)) + .is_none(); + (comment.id, outdated) + }) + .collect::>() + }); + self.comment_model.update(ctx, |batch, _| { + for (id, outdated) in outdated_updates { + batch.set_outdated(id, outdated); + } + }); + + let comments = self + .comment_model + .read(ctx, |batch, _| batch.comments().to_vec()); + if !comments.iter().any(|comment| !comment.outdated) { + log::info!("No active plan comments to submit"); + return; + } + + let prompt = build_plan_comment_prompt(&comments); + let document_id = self.document_id; + + terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view + .ai_context_model() + .update(ctx, |context_model, ctx| { + context_model.set_pending_query_state_for_existing_conversation( + conversation_id, + AgentViewEntryOrigin::AIDocument, + ctx, + ); + context_model.set_pending_document(Some(document_id), ctx); + }); + terminal_view + .ai_controller() + .update(ctx, |controller, ctx| { + controller.send_user_query_in_conversation(prompt, conversation_id, None, ctx); + }); + }); + + self.comment_model + .update(ctx, |batch, ctx| batch.clear_all(ctx)); + + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::success("Comments sent to agent".to_string()), + window_id, + ctx, + ); + }); + ctx.notify(); + } } impl Entity for AIDocumentView { @@ -1100,6 +1364,18 @@ impl View for AIDocumentView { .finish(); content_column.add_child(warpui::elements::Expanded::new(1.0, editor).finish()); + if FeatureFlag::PlanComments.is_enabled() { + if let Some(composer) = &self.comment_composer { + content_column.add_child( + Container::new(ChildView::new(composer).finish()) + .with_horizontal_padding(8.) + .with_padding_bottom(8.) + .finish(), + ); + } + content_column.add_child(ChildView::new(&self.comment_list_view).finish()); + } + let mut stack = Stack::new().with_child(content_column.finish()); if self.is_version_menu_open { @@ -1263,6 +1539,9 @@ impl TypedActionView for AIDocumentView { AIDocumentAction::AttachToActiveSession => { ctx.emit(AIDocumentEvent::AttachPlanAsContext(self.document_id)); } + AIDocumentAction::AddComment => { + self.add_comment(ctx); + } } } } diff --git a/app/src/ai/document/mod.rs b/app/src/ai/document/mod.rs index 355fa10f87..9e78876168 100644 --- a/app/src/ai/document/mod.rs +++ b/app/src/ai/document/mod.rs @@ -1,3 +1,5 @@ pub mod ai_document_model; pub mod orchestration_config_block; +pub mod plan_comment_list_view; +pub mod plan_comments; pub(in crate::ai) mod plan_publication; diff --git a/app/src/ai/document/plan_comment_list_view.rs b/app/src/ai/document/plan_comment_list_view.rs new file mode 100644 index 0000000000..6a064f6ec6 --- /dev/null +++ b/app/src/ai/document/plan_comment_list_view.rs @@ -0,0 +1,518 @@ +//! Bottom panel that lists comments attached to a planning document. +//! +//! This mirrors the code review `CommentListView` UX (a collapsible, resizable list with Cancel / +//! "Send to Agent" actions) but is tailored to plan comments: there is no diff content, file paths, +//! or GitHub origin. It reuses the shared card rendering primitives from +//! [`crate::code_review::comment_rendering`] so the cards look identical to code review. + +use chrono::{Duration, Local}; +use indexmap::IndexMap; +use warp_core::ui::theme::color::internal_colors::{neutral_3, neutral_4, text_sub}; +use warpui::elements::new_scrollable::{NewScrollable, ScrollableAppearance, SingleAxisConfig}; +use warpui::elements::resizable::{ + resizable_state_handle, DragBarSide, Resizable, ResizableStateHandle, +}; +use warpui::elements::{ + Border, ChildView, Clipped, ClippedScrollStateHandle, CornerRadius, CrossAxisAlignment, + DispatchEventResult, Element, Empty, EventHandler, Expanded, Flex, Hoverable, + MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, + Shrinkable, Stack, +}; +use warpui::platform::Cursor; +use warpui::ui_components::button::{ButtonTooltipPosition, ButtonVariant}; +use warpui::ui_components::components::{UiComponent, UiComponentStyles}; +use warpui::units::Pixels; +use warpui::{ + AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, + ViewHandle, +}; + +use crate::ai::document::plan_comments::{ + PlanComment, PlanCommentBatch, PlanCommentBatchEvent, PlanCommentId, PlanCommentTarget, +}; +use crate::ai::AIRequestUsageModel; +use crate::appearance::Appearance; +use crate::code::editor::comment_editor::{ + create_readonly_comment_markdown_editor, DEFAULT_COMMENT_MAX_WIDTH, +}; +use crate::code_review::comment_rendering::{ + comment_card_container, render_comment_file_path_header, render_comment_text_section, +}; +use crate::settings::AISettings; +use crate::ui_components::icons::Icon; +use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme}; + +/// Maximum length of the quoted-text snippet shown as a card title. +const TITLE_MAX_CHARS: usize = 80; + +#[derive(Clone, Debug, PartialEq)] +pub enum PlanCommentListAction { + ToggleCollapsed, + Cancel, + Submit, + EditComment(PlanCommentId), + DeleteComment(PlanCommentId), +} + +#[derive(Clone, Debug)] +pub enum PlanCommentListEvent { + Submitted, + Cancelled, + EditComment(PlanCommentId), + DeleteComment(PlanCommentId), +} + +struct CardState { + body_editor: ViewHandle, + edit_button: ViewHandle, + remove_button: ViewHandle, + source: PlanComment, + title: String, + last_updated_duration: Duration, +} + +struct ViewState { + scroll_state: ClippedScrollStateHandle, + chevron_mouse_state: MouseStateHandle, + cancel_button_mouse_state: MouseStateHandle, + submit_button_mouse_state: MouseStateHandle, + resizable_state: ResizableStateHandle, +} + +impl Default for ViewState { + fn default() -> Self { + Self { + scroll_state: Default::default(), + chevron_mouse_state: Default::default(), + cancel_button_mouse_state: Default::default(), + submit_button_mouse_state: Default::default(), + resizable_state: resizable_state_handle(300.0), + } + } +} + +pub struct PlanCommentListView { + cards: IndexMap, + is_collapsed: bool, + view_state: ViewState, +} + +impl PlanCommentListView { + pub fn new(comment_model: ModelHandle, ctx: &mut ViewContext) -> Self { + ctx.subscribe_to_model( + &comment_model, + |me, model, _event: &PlanCommentBatchEvent, ctx| { + me.refresh_from_model(&model, ctx); + }, + ); + + let mut me = Self { + cards: IndexMap::new(), + is_collapsed: false, + view_state: ViewState::default(), + }; + me.refresh_from_model(&comment_model, ctx); + me + } + + fn refresh_from_model( + &mut self, + model: &ModelHandle, + ctx: &mut ViewContext, + ) { + let comments = model.read(ctx, |batch, _| batch.comments().to_vec()); + let mut new_cards = IndexMap::with_capacity(comments.len()); + + for comment in comments { + let id = comment.id; + let card = if let Some(mut existing) = self.cards.shift_remove(&id) { + // Reset the body editor content in case it changed. + existing.body_editor.update(ctx, |editor, ctx| { + editor.model().update(ctx, |model, ctx| { + model.reset_with_markdown(&comment.body, ctx); + }); + }); + existing.title = Self::comment_title(&comment); + existing.last_updated_duration = Local::now() - comment.last_update_time; + existing.source = comment; + existing + } else { + Self::create_card(comment, ctx) + }; + new_cards.insert(id, card); + } + + self.cards = new_cards; + ctx.notify(); + } + + fn create_card(comment: PlanComment, ctx: &mut ViewContext) -> CardState { + let body_editor = create_readonly_comment_markdown_editor( + &comment.body, + false, + Some(Pixels::new(DEFAULT_COMMENT_MAX_WIDTH)), + ctx, + ); + + let id = comment.id; + let edit_button = ctx.add_view(|_| { + ActionButton::new("", NakedTheme) + .with_icon(Icon::Pencil) + .with_size(ButtonSize::Small) + .on_click(move |ctx| { + ctx.dispatch_typed_action(PlanCommentListAction::EditComment(id)); + }) + }); + let remove_button = ctx.add_view(|_| { + ActionButton::new("", NakedTheme) + .with_icon(Icon::Trash) + .with_size(ButtonSize::Small) + .on_click(move |ctx| { + ctx.dispatch_typed_action(PlanCommentListAction::DeleteComment(id)); + }) + }); + + let title = Self::comment_title(&comment); + let last_updated_duration = Local::now() - comment.last_update_time; + + CardState { + body_editor, + edit_button, + remove_button, + source: comment, + title, + last_updated_duration, + } + } + + /// The card title: a single-line, truncated snippet of the quoted text, or a generic label for + /// document-level comments. + fn comment_title(comment: &PlanComment) -> String { + match &comment.target { + PlanCommentTarget::DocumentRange { quoted_text, .. } => { + let single_line = quoted_text.split('\n').next().unwrap_or("").trim(); + if single_line.is_empty() { + return "Plan comment".to_string(); + } + if single_line.chars().count() > TITLE_MAX_CHARS { + let truncated: String = single_line.chars().take(TITLE_MAX_CHARS).collect(); + format!("{truncated}…") + } else { + single_line.to_string() + } + } + PlanCommentTarget::General => "Plan".to_string(), + } + } + + pub fn expand(&mut self, ctx: &mut ViewContext) { + if self.is_collapsed { + self.is_collapsed = false; + ctx.notify(); + } + } + + fn has_active_comments(&self) -> bool { + self.cards.values().any(|card| !card.source.outdated) + } + + fn render_header(&self, appearance: &Appearance, ctx: &AppContext) -> Box { + let theme = appearance.theme(); + let mut header_row = Flex::row() + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Center); + header_row.add_child(self.render_header_left(appearance)); + header_row.add_child(self.render_header_right(appearance, ctx)); + + warpui::elements::Container::new( + Clipped::new(Shrinkable::new(1., header_row.finish()).finish()).finish(), + ) + .with_background(neutral_3(theme)) + .with_vertical_padding(8.) + .with_horizontal_padding(16.) + .with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.))) + .with_border( + Border::new(1.) + .with_sides(true, true, false, true) + .with_border_fill(warp_core::ui::theme::Fill::Solid(neutral_4(theme))), + ) + .finish() + } + + fn render_header_left(&self, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let count = self.cards.len(); + let label = format!("{count} comment{}", if count == 1 { "" } else { "s" }); + + let icon = if self.is_collapsed { + Icon::ChevronRight + } else { + Icon::ChevronDown + }; + let icon_element = icon + .to_warpui_icon(warp_core::ui::theme::Fill::Solid(text_sub( + theme, + neutral_3(theme), + ))) + .finish(); + + let toggle = Hoverable::new(self.view_state.chevron_mouse_state.clone(), move |_| { + warpui::elements::ConstrainedBox::new(icon_element) + .with_width(16.) + .with_height(16.) + .finish() + }) + .with_cursor(Cursor::PointingHand) + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(PlanCommentListAction::ToggleCollapsed); + }) + .finish(); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + warpui::elements::Container::new(toggle) + .with_margin_right(8.) + .finish(), + ) + .with_child( + warpui::elements::Text::new( + label, + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_color( + theme + .main_text_color(warp_core::ui::theme::Fill::Solid(neutral_3(theme))) + .into_solid(), + ) + .finish(), + ) + .finish() + } + + fn render_header_right(&self, appearance: &Appearance, ctx: &AppContext) -> Box { + let mut right_section = Flex::row() + .with_main_axis_alignment(MainAxisAlignment::End) + .with_cross_axis_alignment(CrossAxisAlignment::Center); + right_section.add_child(self.render_cancel_button(appearance)); + right_section.add_child(self.render_send_button(appearance, ctx)); + right_section.finish() + } + + fn render_cancel_button(&self, appearance: &Appearance) -> Box { + let cancel_button = EventHandler::new( + appearance + .ui_builder() + .button( + ButtonVariant::Text, + self.view_state.cancel_button_mouse_state.clone(), + ) + .with_text_label("Cancel".to_string()) + .build() + .finish(), + ) + .on_left_mouse_down(|ctx, _, _| { + ctx.dispatch_typed_action(PlanCommentListAction::Cancel); + DispatchEventResult::StopPropagation + }) + .finish(); + warpui::elements::Container::new(cancel_button) + .with_margin_right(8.) + .finish() + } + + fn render_send_button(&self, appearance: &Appearance, ctx: &AppContext) -> Box { + let ai_available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx); + let ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx); + let has_active = self.has_active_comments(); + let enable_send = ai_available && ai_enabled && has_active; + + let tooltip_text = if !ai_enabled { + "AI must be enabled to send comments to Agent" + } else if !ai_available { + "Agent review requires AI credits" + } else if !has_active { + "No comments to send" + } else { + "Send plan comments to Agent" + }; + + let tooltip = appearance + .ui_builder() + .tool_tip(tooltip_text.to_string()) + .build() + .finish(); + + let button = appearance + .ui_builder() + .button( + ButtonVariant::Accent, + self.view_state.submit_button_mouse_state.clone(), + ) + .with_text_label("Send to Agent".to_string()) + .with_tooltip(|| tooltip) + .with_tooltip_position(ButtonTooltipPosition::AboveLeft); + + if enable_send { + EventHandler::new(button.build().finish()) + .on_left_mouse_down(|ctx, _, _| { + ctx.dispatch_typed_action(PlanCommentListAction::Submit); + DispatchEventResult::StopPropagation + }) + .finish() + } else { + let background_fill = appearance.theme().surface_3(); + let foreground_color = appearance + .theme() + .disabled_text_color(background_fill) + .into_solid(); + button + .with_style(UiComponentStyles { + background: Some(background_fill.into_solid().into()), + border_color: Some(foreground_color.into()), + font_color: Some(foreground_color), + ..Default::default() + }) + .build() + .finish() + } + } + + fn render_panel(&self, appearance: &Appearance, ctx: &AppContext) -> Box { + let theme = appearance.theme(); + let header = self.render_header(appearance, ctx); + + let mut comments_column = Flex::column() + .with_main_axis_alignment(MainAxisAlignment::Start) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch); + + for card in self.cards.values() { + comments_column.add_child( + warpui::elements::Container::new(self.render_card(card, appearance)) + .with_margin_bottom(12.) + .finish(), + ); + } + + let scrollable_content = NewScrollable::vertical( + SingleAxisConfig::Clipped { + handle: self.view_state.scroll_state.clone(), + child: warpui::elements::Container::new(comments_column.finish()) + .with_uniform_padding(16.) + .finish(), + }, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + warpui::elements::Fill::None, + ) + .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false)) + .with_propagate_mousewheel_if_not_handled(true) + .finish(); + + Flex::column() + .with_main_axis_size(MainAxisSize::Max) + .with_child(header) + .with_child(Expanded::new(1., scrollable_content).finish()) + .finish() + } + + fn render_card(&self, card: &CardState, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + + let trailing = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(ChildView::new(&card.edit_button).finish()) + .with_child(ChildView::new(&card.remove_button).finish()) + .finish(); + + let header = render_comment_file_path_header( + &card.title, + card.source.outdated, + Some(trailing), + CornerRadius::with_top(Radius::Pixels(8.)), + None, + appearance, + ); + + let body = render_comment_text_section( + &card.body_editor, + card.last_updated_duration, + false, /* is_imported_from_github */ + None, + appearance, + ); + + let card_inner = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_children([header, body]) + .finish(); + + comment_card_container(card_inner, theme) + } +} + +impl Entity for PlanCommentListView { + type Event = PlanCommentListEvent; +} + +impl View for PlanCommentListView { + fn ui_name() -> &'static str { + "PlanCommentListView" + } + + fn render(&self, ctx: &AppContext) -> Box { + if self.cards.is_empty() { + return Empty::new().finish(); + } + + let appearance = Appearance::as_ref(ctx); + + if self.is_collapsed { + return self.render_header(appearance, ctx); + } + + let panel = self.render_panel(appearance, ctx); + + Stack::new() + .with_child( + Resizable::new(self.view_state.resizable_state.clone(), panel) + .with_dragbar_side(DragBarSide::Top) + .with_dragbar_color(warpui::elements::Fill::Solid( + warpui::color::ColorU::transparent_black(), + )) + .with_bounds_callback(Box::new(|window_size| (100.0, window_size.y() * 0.8))) + .on_resize(|ctx, _| { + ctx.notify(); + }) + .finish(), + ) + .finish() + } +} + +impl TypedActionView for PlanCommentListView { + type Action = PlanCommentListAction; + + fn handle_action(&mut self, action: &PlanCommentListAction, ctx: &mut ViewContext) { + match action { + PlanCommentListAction::ToggleCollapsed => { + self.is_collapsed = !self.is_collapsed; + ctx.notify(); + } + PlanCommentListAction::Cancel => { + ctx.emit(PlanCommentListEvent::Cancelled); + } + PlanCommentListAction::Submit => { + ctx.emit(PlanCommentListEvent::Submitted); + } + PlanCommentListAction::EditComment(id) => { + ctx.emit(PlanCommentListEvent::EditComment(*id)); + } + PlanCommentListAction::DeleteComment(id) => { + ctx.emit(PlanCommentListEvent::DeleteComment(*id)); + } + } + } +} diff --git a/app/src/ai/document/plan_comments.rs b/app/src/ai/document/plan_comments.rs new file mode 100644 index 0000000000..73fb554f8f --- /dev/null +++ b/app/src/ai/document/plan_comments.rs @@ -0,0 +1,207 @@ +//! Data model for code-review-style comments attached to a planning document. +//! +//! Unlike code review comments (which target diff lines/files), plan comments target a range of +//! text within a single rich-text plan document, anchored via the editor's [`Anchor`] system so +//! the range follows edits. A comment can also be `General` (applies to the whole plan). + +use std::fmt::{Display, Formatter}; +use std::ops::Range; + +use chrono::{DateTime, Local}; +use string_offset::CharOffset; +use warp_editor::content::anchor::Anchor; +use warp_editor::content::selection_model::BufferSelectionModel; +use warpui::{Entity, ModelContext}; + +/// Locally-generated identifier for a plan comment. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct PlanCommentId(uuid::Uuid); + +impl PlanCommentId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for PlanCommentId { + fn default() -> Self { + Self::new() + } +} + +impl Display for PlanCommentId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Where a plan comment is attached within the plan document. +#[derive(Clone, Debug)] +pub enum PlanCommentTarget { + /// Anchored to a range of text in the plan document. The `head`/`tail` anchors are created + /// from the plan editor's [`BufferSelectionModel`] and are kept in sync as the document is + /// edited. `quoted_text` is a snapshot of the selected text at creation time, used as the card + /// title and for context when submitting to the agent. + DocumentRange { + head: Anchor, + tail: Anchor, + quoted_text: String, + }, + /// Applies to the whole plan document. + General, +} + +/// A single comment attached to a planning document. +#[derive(Clone, Debug)] +pub struct PlanComment { + pub id: PlanCommentId, + /// The user-authored comment body (markdown). + pub body: String, + pub target: PlanCommentTarget, + pub last_update_time: DateTime, + /// True if the anchored range no longer resolves (its text was deleted). Outdated comments are + /// still shown but are excluded when submitting to the agent. + pub outdated: bool, +} + +impl PlanComment { + /// Creates a new comment anchored to a document range. + pub fn new_range(body: String, head: Anchor, tail: Anchor, quoted_text: String) -> Self { + Self { + id: PlanCommentId::new(), + body, + target: PlanCommentTarget::DocumentRange { + head, + tail, + quoted_text, + }, + last_update_time: Local::now(), + outdated: false, + } + } + + /// Creates a new general (document-level) comment. + pub fn new_general(body: String) -> Self { + Self { + id: PlanCommentId::new(), + body, + target: PlanCommentTarget::General, + last_update_time: Local::now(), + outdated: false, + } + } + + /// The quoted text snapshot for range comments, if any. + pub fn quoted_text(&self) -> Option<&str> { + match &self.target { + PlanCommentTarget::DocumentRange { quoted_text, .. } => Some(quoted_text.as_str()), + PlanCommentTarget::General => None, + } + } + + /// Resolves the comment's current character range using the plan editor's selection model. + /// + /// Returns `None` for general comments or when either anchor no longer resolves (e.g. the + /// commented text was deleted). + pub fn resolve_range( + &self, + selection_model: &BufferSelectionModel, + ) -> Option> { + match &self.target { + PlanCommentTarget::DocumentRange { head, tail, .. } => { + let head = selection_model.resolve_anchor(head)?; + let tail = selection_model.resolve_anchor(tail)?; + Some(if head <= tail { head..tail } else { tail..head }) + } + PlanCommentTarget::General => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PlanCommentBatchEvent { + Changed, +} + +/// A collection of comments attached to a single planning document. +#[derive(Default)] +pub struct PlanCommentBatch { + comments: Vec, +} + +impl Entity for PlanCommentBatch { + type Event = PlanCommentBatchEvent; +} + +impl PlanCommentBatch { + pub fn comments(&self) -> &[PlanComment] { + &self.comments + } + + pub fn get(&self, id: PlanCommentId) -> Option<&PlanComment> { + self.comments.iter().find(|comment| comment.id == id) + } + + /// Inserts a new comment or updates an existing one (matched by ID) in place. + pub fn upsert_comment(&mut self, comment: PlanComment, ctx: &mut ModelContext) { + if let Some(existing) = self.comments.iter_mut().find(|c| c.id == comment.id) { + *existing = comment; + } else { + self.comments.push(comment); + } + ctx.emit(PlanCommentBatchEvent::Changed); + } + + pub fn delete_comment(&mut self, id: PlanCommentId, ctx: &mut ModelContext) { + self.comments.retain(|comment| comment.id != id); + ctx.emit(PlanCommentBatchEvent::Changed); + } + + pub fn clear_all(&mut self, ctx: &mut ModelContext) { + self.comments.clear(); + ctx.emit(PlanCommentBatchEvent::Changed); + } + + /// Updates the `outdated` flag for the comment with the given ID. Returns `true` if the flag + /// changed. + pub fn set_outdated(&mut self, id: PlanCommentId, outdated: bool) -> bool { + if let Some(comment) = self.comments.iter_mut().find(|c| c.id == id) { + if comment.outdated != outdated { + comment.outdated = outdated; + return true; + } + } + false + } +} + +/// Builds the user-facing query text sent to the agent when submitting plan comments. +/// +/// Centralizing the formatting here keeps the submission call site simple and makes it easy to +/// swap in a dedicated, server-formatted `PlanReview` input type in the future (which would +/// require proto/server changes). Outdated comments are skipped. +pub fn build_plan_comment_prompt(comments: &[PlanComment]) -> String { + let mut text = String::from( + "Please revise the plan to address the following comments. Update the planning document accordingly.\n", + ); + + for comment in comments.iter().filter(|comment| !comment.outdated) { + let body = comment.body.trim(); + match comment.quoted_text() { + Some(quoted) if !quoted.trim().is_empty() => { + // Collapse the quoted snippet to a single line for a concise reference. + let snippet = quoted.split('\n').next().unwrap_or("").trim(); + text.push_str(&format!("\n- On \"{snippet}\": {body}")); + } + _ => { + text.push_str(&format!("\n- {body}")); + } + } + } + + text +} + +#[cfg(test)] +#[path = "plan_comments_tests.rs"] +mod tests; diff --git a/app/src/ai/document/plan_comments_tests.rs b/app/src/ai/document/plan_comments_tests.rs new file mode 100644 index 0000000000..20f39e7757 --- /dev/null +++ b/app/src/ai/document/plan_comments_tests.rs @@ -0,0 +1,105 @@ +use warpui::App; + +use super::{PlanComment, PlanCommentBatch}; + +#[test] +fn upsert_replaces_existing_comment_in_place() { + App::test((), |mut app| async move { + let model = app.add_model(|_| PlanCommentBatch::default()); + + let mut comment = PlanComment::new_general("first".to_string()); + let id = comment.id; + + model.update(&mut app, |batch, ctx| { + batch.upsert_comment(comment.clone(), ctx); + }); + + model.read(&app, |batch, _| { + assert_eq!(batch.comments().len(), 1); + assert_eq!(batch.comments()[0].id, id); + assert_eq!(batch.comments()[0].body, "first"); + }); + + comment.body = "updated".to_string(); + model.update(&mut app, |batch, ctx| { + batch.upsert_comment(comment.clone(), ctx); + }); + + model.read(&app, |batch, _| { + assert_eq!(batch.comments().len(), 1); + assert_eq!(batch.comments()[0].id, id); + assert_eq!(batch.comments()[0].body, "updated"); + }); + }); +} + +#[test] +fn delete_and_clear_mutations_work() { + App::test((), |mut app| async move { + let model = app.add_model(|_| PlanCommentBatch::default()); + + let comment_a = PlanComment::new_general("a".to_string()); + let comment_b = PlanComment::new_general("b".to_string()); + let id_a = comment_a.id; + + model.update(&mut app, |batch, ctx| { + batch.upsert_comment(comment_a.clone(), ctx); + batch.upsert_comment(comment_b.clone(), ctx); + }); + + model.update(&mut app, |batch, ctx| { + batch.delete_comment(id_a, ctx); + }); + + model.read(&app, |batch, _| { + assert_eq!(batch.comments().len(), 1); + assert_eq!(batch.comments()[0].body, "b"); + }); + + model.update(&mut app, |batch, ctx| { + batch.clear_all(ctx); + }); + + model.read(&app, |batch, _| { + assert!(batch.comments().is_empty()); + }); + }); +} + +#[test] +fn active_comments_excludes_outdated() { + App::test((), |mut app| async move { + let model = app.add_model(|_| PlanCommentBatch::default()); + + let comment_a = PlanComment::new_general("a".to_string()); + let comment_b = PlanComment::new_general("b".to_string()); + let id_b = comment_b.id; + + model.update(&mut app, |batch, ctx| { + batch.upsert_comment(comment_a.clone(), ctx); + batch.upsert_comment(comment_b.clone(), ctx); + }); + + model.update(&mut app, |batch, _| { + assert!(batch.set_outdated(id_b, true)); + // Setting the same value again is a no-op. + assert!(!batch.set_outdated(id_b, true)); + }); + + model.read(&app, |batch, _| { + let active: Vec<_> = batch + .comments() + .iter() + .filter(|c| !c.outdated) + .map(|c| c.body.as_str()) + .collect(); + assert_eq!(active, vec!["a"]); + }); + }); +} + +#[test] +fn general_comment_has_no_quoted_text_or_range() { + let comment = PlanComment::new_general("body".to_string()); + assert_eq!(comment.quoted_text(), None); +} diff --git a/app/src/code/editor/comment_editor.rs b/app/src/code/editor/comment_editor.rs index 9016609f1f..1ec41642e9 100644 --- a/app/src/code/editor/comment_editor.rs +++ b/app/src/code/editor/comment_editor.rs @@ -544,6 +544,7 @@ where can_execute_shell_commands: Some(false), disable_block_insertion_menu: true, disable_scrolling, + comments_enabled: false, }, ctx, ) diff --git a/app/src/code_review/comment_rendering.rs b/app/src/code_review/comment_rendering.rs index 2fb99045e7..11f1f6c577 100644 --- a/app/src/code_review/comment_rendering.rs +++ b/app/src/code_review/comment_rendering.rs @@ -41,7 +41,7 @@ pub(crate) struct HeaderClickHandler { /// Wraps the given content element in the standard comment card chrome /// (rounded corners, neutral background, outline border). -fn comment_card_container( +pub(crate) fn comment_card_container( content: Box, theme: &warp_core::ui::theme::WarpTheme, ) -> Box { @@ -75,7 +75,7 @@ fn render_collapsed_comment_card( comment_card_container(header, theme) } -fn render_comment_file_path_header( +pub(crate) fn render_comment_file_path_header( title: &str, is_outdated: bool, trailing_element: Option>, @@ -155,7 +155,7 @@ fn render_comment_file_path_header( } } -fn render_comment_text_section( +pub(crate) fn render_comment_text_section( comment_editor: &ViewHandle, last_updated_duration: Duration, is_imported_from_github: bool, diff --git a/app/src/features.rs b/app/src/features.rs index 729387fa0d..cbddfc3383 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -315,6 +315,8 @@ fn enabled_features() -> HashSet { FeatureFlag::GlobalSearch, #[cfg(feature = "embedded_code_review_comments")] FeatureFlag::EmbeddedCodeReviewComments, + #[cfg(feature = "plan_comments")] + FeatureFlag::PlanComments, #[cfg(feature = "file_and_diff_set_comments")] FeatureFlag::FileAndDiffSetComments, #[cfg(feature = "revert_to_checkpoints")] diff --git a/app/src/notebooks/editor/model.rs b/app/src/notebooks/editor/model.rs index f6053714aa..96f3611952 100644 --- a/app/src/notebooks/editor/model.rs +++ b/app/src/notebooks/editor/model.rs @@ -18,6 +18,7 @@ use vec1::{vec1, Vec1}; use warp_core::features::FeatureFlag; use warp_core::r#async::debounce; use warp_core::semantic_selection::SemanticSelection; +use warp_editor::content::anchor::Anchor; use warp_editor::content::buffer::{ AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin, SelectionOffsets, ShouldAutoscroll, @@ -1202,6 +1203,33 @@ impl NotebooksEditorModel { .into_string() } + /// Creates anchors tracking the current selection range, returning `(head, tail, quoted_text)`. + /// + /// Returns `None` if the selection is empty (a single cursor with no selected text). The + /// anchors are created on the same [`BufferSelectionModel`] the buffer updates on edits, so they + /// follow subsequent edits and can be resolved later via + /// [`BufferSelectionModel::resolve_anchor`]. + pub fn create_selection_anchors( + &self, + ctx: &mut ModelContext, + ) -> Option<(Anchor, Anchor, String)> { + let range = self + .selection_model + .as_ref(ctx) + .selection_to_first_offset_range(); + if range.start == range.end { + return None; + } + let quoted_text = self.selected_text(ctx); + let (head, tail) = self.selection_model.update(ctx, |selection_model, ctx| { + ( + selection_model.anchor(range.start, ctx), + selection_model.anchor(range.end, ctx), + ) + }); + Some((head, tail, quoted_text)) + } + pub fn has_single_exact_rendered_mermaid_selection(&self, ctx: &AppContext) -> bool { if !self.selection_is_single_range(ctx) { return false; diff --git a/app/src/notebooks/editor/omnibar.rs b/app/src/notebooks/editor/omnibar.rs index 021eb2ab38..8aaf9fbaef 100644 --- a/app/src/notebooks/editor/omnibar.rs +++ b/app/src/notebooks/editor/omnibar.rs @@ -39,6 +39,8 @@ const ACTION_BUTTON_SIZE: f32 = 24.; pub enum OmnibarEvent { OpenLinkEditor, + /// The user clicked the "Comment" button to comment on the current selection. + AddComment, } /// View to render the omnibar. @@ -53,13 +55,21 @@ pub struct Omnibar { strikethrough_button_state: MouseStateHandle, link_button_state: MouseStateHandle, inline_code_button_state: MouseStateHandle, + comment_button_state: MouseStateHandle, + + /// When true, a "Comment" button is shown that lets the user comment on the selection. + comments_enabled: bool, active_text_styles: Option, active_block_type: Option, } impl Omnibar { - pub fn new(model: ModelHandle, ctx: &mut ViewContext) -> Self { + pub fn new( + model: ModelHandle, + comments_enabled: bool, + ctx: &mut ViewContext, + ) -> Self { let block_conversion_dropdown = ctx.add_typed_action_view(|ctx| { let mut dropdown = CompactDropdown::new(MenuVariant::Fixed, ctx); let appearance = Appearance::as_ref(ctx); @@ -85,6 +95,8 @@ impl Omnibar { italicize_button_state: Default::default(), underline_button_state: Default::default(), inline_code_button_state: Default::default(), + comment_button_state: Default::default(), + comments_enabled, active_text_styles: None, active_block_type: None, } @@ -355,6 +367,17 @@ impl View for Omnibar { ); } + if self.comments_enabled { + actions.add_child(self.render_separator(appearance)); + actions.add_child(self.render_action_button( + appearance, + Icon::MessagePlusSquare, + OmnibarAction::AddComment, + false, + &self.comment_button_state, + )); + } + let bar = Container::new( ConstrainedBox::new(actions.finish()) .with_height(OMNIBAR_HEIGHT - 2. * OMNIBAR_PADDING) @@ -385,6 +408,8 @@ pub enum OmnibarAction { UnstyleLink, /// Convert the selected text to a particular kind of block. ConvertBlock(BufferBlockStyle), + /// Comment on the current selection. + AddComment, } impl TypedActionView for Omnibar { @@ -410,6 +435,7 @@ impl TypedActionView for Omnibar { OmnibarAction::ConvertBlock(style) => { self.convert_block(style.clone(), ctx); } + OmnibarAction::AddComment => ctx.emit(OmnibarEvent::AddComment), } } @@ -449,6 +475,9 @@ impl TypedActionView for Omnibar { OmnibarAction::UnstyleLink => ActionAccessibilityContent::Custom( AccessibilityContent::new_without_help("Remove link", WarpA11yRole::UserAction), ), + OmnibarAction::AddComment => ActionAccessibilityContent::Custom( + AccessibilityContent::new_without_help("Comment", WarpA11yRole::UserAction), + ), } } } diff --git a/app/src/notebooks/editor/view.rs b/app/src/notebooks/editor/view.rs index 707d0140df..cbd1aac9b7 100644 --- a/app/src/notebooks/editor/view.rs +++ b/app/src/notebooks/editor/view.rs @@ -971,6 +971,8 @@ pub enum EditorViewEvent { /// Escape was pressed (emitted when shell command execution is disabled, /// e.g. in comment editors). EscapePressed, + /// The user requested to comment on the current selection (via the omnibar "Comment" button). + AddComment, } #[derive(Default)] @@ -1092,6 +1094,10 @@ pub struct RichTextEditorConfig { /// Enable or disable the block insertion menu (slash menu). /// When disabled, typing "/" will not open the menu. pub disable_block_insertion_menu: bool, + + /// When true, the selection omnibar shows a "Comment" button. Used by planning documents to + /// let users comment on a selection. + pub comments_enabled: bool, } impl RichTextEditorView { @@ -1135,7 +1141,9 @@ impl RichTextEditorView { ctx.notify(); }); - let omnibar = ctx.add_typed_action_view(|ctx| Omnibar::new(model.clone(), ctx)); + let comments_enabled = config.comments_enabled; + let omnibar = + ctx.add_typed_action_view(|ctx| Omnibar::new(model.clone(), comments_enabled, ctx)); ctx.subscribe_to_view(&omnibar, Self::handle_omnibar_event); let link_editor = ctx.add_typed_action_view(|ctx| LinkEditor::new(model.clone(), ctx)); @@ -1189,8 +1197,10 @@ impl RichTextEditorView { event: &OmnibarEvent, ctx: &mut ViewContext, ) { - if matches!(event, OmnibarEvent::OpenLinkEditor) { - self.open_link_editor(ctx); + match event { + OmnibarEvent::OpenLinkEditor => self.open_link_editor(ctx), + // Forward to the parent view (e.g. the plan document) to open the comment composer. + OmnibarEvent::AddComment => ctx.emit(EditorViewEvent::AddComment), } } diff --git a/app/src/notebooks/file/mod.rs b/app/src/notebooks/file/mod.rs index 9362107a94..b78e849285 100644 --- a/app/src/notebooks/file/mod.rs +++ b/app/src/notebooks/file/mod.rs @@ -775,7 +775,8 @@ impl FileNotebookView { | EditorViewEvent::EditWorkflow(_) | EditorViewEvent::CmdEnter | EditorViewEvent::EscapePressed - | EditorViewEvent::TextSelectionChanged => (), + | EditorViewEvent::TextSelectionChanged + | EditorViewEvent::AddComment => (), EditorViewEvent::OpenFile { .. } => { // We don't support opening files from the notebook view. // File paths rely on a Session to be present, and this is only set from the AI document view today. diff --git a/app/src/notebooks/notebook.rs b/app/src/notebooks/notebook.rs index 01f1d46ba2..f74797c4c5 100644 --- a/app/src/notebooks/notebook.rs +++ b/app/src/notebooks/notebook.rs @@ -997,7 +997,8 @@ impl NotebookView { } EditorViewEvent::CmdEnter | EditorViewEvent::EscapePressed - | EditorViewEvent::TextSelectionChanged => (), + | EditorViewEvent::TextSelectionChanged + | EditorViewEvent::AddComment => (), } } diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 18bcbb699f..9ab7370400 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -518,6 +518,10 @@ pub enum FeatureFlag { /// Enables embedded code review comments. EmbeddedCodeReviewComments, + /// Enables the code-review-style commenting UI on planning documents, letting + /// users attach comments to the plan and submit them to the agent. + PlanComments, + /// Enables the revert to checkpoints feature. RevertToCheckpoints, @@ -935,6 +939,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::CodebaseIndexSpeedbump, // End manually enabled Code features. FeatureFlag::EditableMarkdownMermaid, + FeatureFlag::PlanComments, FeatureFlag::CodeReviewScrollPreservation, FeatureFlag::RememberFastForwardState, FeatureFlag::CodexPlugin,