From af706c27cafaf207b9995ab90a90e6ecda1e4dbf Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 31 May 2026 13:36:31 +0800 Subject: [PATCH] fix(ai): collapse design checklist --- crates/op-editor-core/src/chat.rs | 21 +++++ .../src/widgets/ai_chat_checklist.rs | 44 ++++++++++- .../op-editor-ui/src/widgets/ai_chat_hit.rs | 54 +++++++++++++ .../op-editor-ui/src/widgets/ai_chat_panel.rs | 78 ++++++------------- .../src/widgets/ai_chat_panel/tests.rs | 41 ++++++++++ .../src/widgets/ai_chat_panel_controls.rs | 2 +- crates/op-editor-ui/src/widgets/mod.rs | 6 +- .../op-host-native/src/widget_host/click.rs | 5 ++ crates/op-host-web/src/widget_host/press.rs | 5 ++ 9 files changed, 194 insertions(+), 62 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/ai_chat_hit.rs diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index e8fde66a1..674ed3b7c 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -275,6 +275,9 @@ pub struct ChatState { /// the canvas region with a small inset, mirroring the TS app's /// expanded panel. pub maximized: bool, + /// Collapsed state for the fixed "Pencil it out" design checklist + /// pinned above the input, mirroring the TS checklist header. + pub checklist_collapsed: bool, /// Last user-action timestamp (focus / keystroke) in ms — drives /// the caret blink phase. Reset on focus and on every key event. pub caret_anchor_ms: u64, @@ -337,6 +340,7 @@ impl Default for ChatState { anchor: ChatAnchor::BottomLeft, collapsed: false, maximized: false, + checklist_collapsed: false, caret_anchor_ms: 0, pending_send: None, pending_new_chat: false, @@ -478,6 +482,11 @@ impl ChatState { } } + /// Flip the fixed design-checklist panel state. + pub fn toggle_checklist_collapsed(&mut self) { + self.checklist_collapsed = !self.checklist_collapsed; + } + /// Advance the thinking-mode selector one step: /// Adaptive → Disabled → Enabled → Adaptive. pub fn cycle_thinking_mode(&mut self) { @@ -821,6 +830,18 @@ mod tests { chat.toggle_message_tool_calls(99); } + #[test] + fn toggle_checklist_collapsed_flips_panel_checklist_flag() { + let mut chat = ChatState::default(); + assert!(!chat.checklist_collapsed); + + chat.toggle_checklist_collapsed(); + assert!(chat.checklist_collapsed); + + chat.toggle_checklist_collapsed(); + assert!(!chat.checklist_collapsed); + } + #[test] fn nearest_anchor_picks_corner() { let p = crate::render_backend::Point2D::new(10.0, 10.0); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs b/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs index 510e237fa..ac7609813 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs @@ -13,8 +13,8 @@ use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::chat::{ChatMessage, ChatRole}; -const PROGRESS_H: f32 = 2.0; -const HEADER_H: f32 = 32.0; +pub(crate) const PROGRESS_H: f32 = 2.0; +pub(crate) const HEADER_H: f32 = 32.0; const ITEM_H: f32 = 22.0; const ITEM_GAP: f32 = 1.0; const BOTTOM_PAD: f32 = 8.0; @@ -77,11 +77,14 @@ pub(crate) fn fixed_checklist_items(messages: &[ChatMessage]) -> Vec f32 { +pub(crate) fn fixed_checklist_height(messages: &[ChatMessage], collapsed: bool) -> f32 { let count = fixed_checklist_items(messages).len(); if count == 0 { return 0.0; } + if collapsed { + return PROGRESS_H + HEADER_H; + } let list_h = (count as f32 * (ITEM_H + ITEM_GAP) - ITEM_GAP).min(MAX_LIST_H); PROGRESS_H + HEADER_H + list_h + BOTTOM_PAD } @@ -101,6 +104,7 @@ pub(crate) fn paint_fixed_checklist( theme: &Theme, rect: Rect, messages: &[ChatMessage], + collapsed: bool, ) { let items = fixed_checklist_items(messages); if items.is_empty() { @@ -155,9 +159,26 @@ pub(crate) fn paint_fixed_checklist( &counter, 10.0, theme.muted_foreground, - rect.origin.x + rect.size.x - PAD - counter_w, + rect.origin.x + rect.size.x - PAD - counter_w - 20.0, header_y + 19.0, ); + draw_icon( + cx.backend, + if collapsed { + Icon::ChevronDown + } else { + Icon::ChevronUp + }, + Point2D::new(rect.origin.x + rect.size.x - PAD - 13.0, header_y + 10.0), + 12.0, + theme.muted_foreground, + 1.4, + ); + + if collapsed { + cx.backend.restore(); + return; + } let mut y = header_y + HEADER_H; for item in &items { @@ -298,4 +319,19 @@ mod tests { assert!(items[1].active); assert_eq!(items[1].label, "Subtask `hero` — Hero section"); } + + #[test] + fn fixed_checklist_height_omits_step_rows_when_collapsed() { + let mut message = ChatMessage::assistant_streaming(); + message.content = r#" +"# + .into(); + + let messages = [message]; + let expanded = fixed_checklist_height(&messages, false); + let collapsed = fixed_checklist_height(&messages, true); + + assert!(expanded > collapsed); + assert_eq!(collapsed, PROGRESS_H + HEADER_H); + } } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs new file mode 100644 index 000000000..331bd0ffb --- /dev/null +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -0,0 +1,54 @@ +/// What a click inside the AI chat panel resolved to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AIChatHit { + /// Click landed on the input area — host should focus chat. + FocusInput, + /// Click landed on the send affordance. + Send, + /// Click landed on an example card; payload is the example's + /// title (host fills the input with this). + Example(String), + /// Click landed on the header / margin — host should start a + /// drag so the user can move the panel between canvas corners. + DragHandle, + /// Click on the chevron at the top-left of the header — host + /// flips the `ChatState::collapsed` flag. + ToggleCollapse, + /// Click on the maximize / restore affordance in the header. + ToggleMaximize, + /// Click on the plus affordance in the header. + NewChat, + /// Click on the model chip (bottom-left of the input toolbar) — + /// host toggles `ui.chat_model_picker_open` to open / close the + /// model dropdown. + ToggleModelPicker, + /// Click on a model row in the open picker dropdown — payload + /// is the index into `chat.available_models` + /// (`Document::select_chat_model`). + SelectModel(usize), + /// Click landed inside the model-picker search/header area. + /// The picker owns keyboard input while open, so this consumes + /// the click without closing the dropdown. + FocusModelSearch, + /// Click on the thinking-mode chip — host cycles + /// `ChatState::thinking_mode`. + CycleThinking, + /// Click on the effort chip — host cycles + /// `ChatState::effort_level`. + CycleEffort, + /// Click on the attach button — host opens a file picker and + /// stages the chosen file via `ChatState::add_attachment`. + AddAttachment, + /// Click on a staged-attachment chip — payload is the index + /// into `chat.pending_attachments` to drop. + RemoveAttachment(usize), + /// Click on a message's thinking-block header — host toggles + /// `ChatMessage::thinking_collapsed` for that message index. + ToggleThinking(usize), + /// Click on a message's tool-calls panel header — host toggles + /// `ChatMessage::tools_collapsed` for that message index. + ToggleToolCalls(usize), + /// Click on the fixed "Pencil it out" checklist header — host + /// toggles the checklist body between expanded and collapsed. + ToggleChecklist, +} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index 85ee12585..69952c1a2 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -1,7 +1,8 @@ use crate::theme::Theme; use crate::widgets::ai_chat_checklist::{ - fixed_checklist_height, fixed_checklist_rect, paint_fixed_checklist, + fixed_checklist_height, fixed_checklist_rect, paint_fixed_checklist, HEADER_H, PROGRESS_H, }; +use crate::widgets::ai_chat_hit::AIChatHit; use crate::widgets::ai_chat_panel_controls::{ attachment_row_hit, paint_attachment_row, ATTACHMENT_ROW_HEIGHT, }; @@ -67,58 +68,6 @@ pub(crate) fn example_cards(locale: op_editor_core::Locale) -> [ExampleCard; 4] ] } -/// What a click inside the panel resolved to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AIChatHit { - /// Click landed on the input area — host should focus chat. - FocusInput, - /// Click landed on the send affordance. - Send, - /// Click landed on an example card; payload is the example's - /// title (host fills the input with this). - Example(String), - /// Click landed on the header / margin — host should start a - /// drag so the user can move the panel between canvas corners. - DragHandle, - /// Click on the chevron at the top-left of the header — host - /// flips the `ChatState::collapsed` flag. - ToggleCollapse, - /// Click on the maximize / restore affordance in the header. - ToggleMaximize, - /// Click on the plus affordance in the header. - NewChat, - /// Click on the model chip (bottom-left of the input toolbar) — - /// host toggles `ui.chat_model_picker_open` to open / close the - /// model dropdown. - ToggleModelPicker, - /// Click on a model row in the open picker dropdown — payload - /// is the index into `chat.available_models` - /// (`Document::select_chat_model`). - SelectModel(usize), - /// Click landed inside the model-picker search/header area. - /// The picker owns keyboard input while open, so this consumes - /// the click without closing the dropdown. - FocusModelSearch, - /// Click on the thinking-mode chip — host cycles - /// `ChatState::thinking_mode`. - CycleThinking, - /// Click on the effort chip — host cycles - /// `ChatState::effort_level`. - CycleEffort, - /// Click on the attach button — host opens a file picker and - /// stages the chosen file via `ChatState::add_attachment`. - AddAttachment, - /// Click on a staged-attachment chip — payload is the index - /// into `chat.pending_attachments` to drop. - RemoveAttachment(usize), - /// Click on a message's thinking-block header — host toggles - /// `ChatMessage::thinking_collapsed` for that message index. - ToggleThinking(usize), - /// Click on a message's tool-calls panel header — host toggles - /// `ChatMessage::tools_collapsed` for that message index. - ToggleToolCalls(usize), -} - pub struct AIChatPlaceholder<'a> { pub id: WidgetId, pub theme: Theme, @@ -221,7 +170,7 @@ impl<'a> AIChatPlaceholder<'a> { - self.input_height() - PAD - 8.0 - - fixed_checklist_height(&self.state.messages); + - fixed_checklist_height(&self.state.messages, self.state.checklist_collapsed); Rect { origin: Point2D::new(rect.origin.x + PAD, body_top), size: Point2D::new(rect.size.x - PAD * 2.0, (body_bottom - body_top).max(0.0)), @@ -361,6 +310,23 @@ impl<'a> AIChatPlaceholder<'a> { } return Some(AIChatHit::FocusInput); } + let checklist_h = + fixed_checklist_height(&self.state.messages, self.state.checklist_collapsed); + if checklist_h > 0.0 { + let checklist = fixed_checklist_rect(rect, input_h, checklist_h); + if rect_contains(checklist, point) { + let header = Rect::xywh( + checklist.origin.x, + checklist.origin.y + PROGRESS_H, + checklist.size.x, + HEADER_H, + ); + if rect_contains(header, point) { + return Some(AIChatHit::ToggleChecklist); + } + return Some(AIChatHit::FocusInput); + } + } // Transcript hit-test — a click on a message's thinking / // tool-call collapsible header toggles it. Checked before the // drag-handle fallback so the headers are interactive. @@ -508,7 +474,8 @@ impl<'a> Widget for AIChatPlaceholder<'a> { ); // Body — either messages or examples. - let checklist_h = fixed_checklist_height(&self.state.messages); + let checklist_h = + fixed_checklist_height(&self.state.messages, self.state.checklist_collapsed); if self.state.messages.is_empty() { paint_examples( cx, @@ -534,6 +501,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { &self.theme, fixed_checklist_rect(rect, input_h, checklist_h), &self.state.messages, + self.state.checklist_collapsed, ); } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index df92e9a52..d5eadd466 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -221,6 +221,47 @@ fn body_rect_reserves_space_for_fixed_step_checklist() { ); } +#[test] +fn body_rect_reserves_less_space_when_fixed_step_checklist_collapsed() { + let mut expanded_state = EditorState::new(); + let mut message = op_editor_core::ChatMessage::assistant_streaming(); + message.content = r#" +"# + .into(); + expanded_state.chat.messages.push(message.clone()); + + let mut collapsed_state = EditorState::new(); + collapsed_state.chat.messages.push(message); + collapsed_state.chat.checklist_collapsed = true; + + let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + let expanded = AIChatPlaceholder::from_editor(&expanded_state).body_rect(rect); + let collapsed = AIChatPlaceholder::from_editor(&collapsed_state).body_rect(rect); + + assert!(collapsed.size.y > expanded.size.y); +} + +#[test] +fn hit_test_resolves_fixed_checklist_header_toggle() { + let mut s = EditorState::new(); + let mut message = op_editor_core::ChatMessage::assistant_streaming(); + message.content = r#" +"# + .into(); + s.chat.messages.push(message); + + let panel = AIChatPlaceholder::from_editor(&s); + let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + let checklist_h = fixed_checklist_height(&s.chat.messages, s.chat.checklist_collapsed); + let checklist = fixed_checklist_rect(rect, INPUT_BASE_HEIGHT, checklist_h); + let p = Point2D::new( + checklist.origin.x + checklist.size.x / 2.0, + checklist.origin.y + 2.0 + 32.0 / 2.0, + ); + + assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::ToggleChecklist)); +} + #[derive(Default)] struct PanelPaintBackend { fills: Vec<(Rect, crate::Color)>, diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs index d724bf2bf..82e3b46f4 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_controls.rs @@ -11,7 +11,7 @@ //! Paint + hit-test share the layout helpers so a click always lands //! on the chip the user sees — no text measurement in the hot path. -use super::ai_chat_panel::AIChatHit; +use super::ai_chat_hit::AIChatHit; use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::PaintCx; diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 10c28717f..8440a4e06 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -111,6 +111,7 @@ pub mod agent_settings_panel; mod agent_settings_panel_tests; pub mod agent_settings_system; mod ai_chat_checklist; +mod ai_chat_hit; pub mod ai_chat_model_picker; pub mod ai_chat_panel; pub mod ai_chat_panel_controls; @@ -165,9 +166,10 @@ pub use canvas_viewport::{ pub use icons::{draw_icon, draw_icon_catalog_entry, draw_icon_data, Icon, IconPathData}; +pub use ai_chat_hit::AIChatHit; pub use ai_chat_panel::{ - AIChatHit, AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, - AI_CHAT_HEIGHT, AI_CHAT_WIDTH, + AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT, + AI_CHAT_WIDTH, }; pub use align_toolbar::{AlignToolbar, ALIGN_TOOLBAR_HEIGHT, ALIGN_TOOLBAR_WIDTH}; pub use component_browser_panel::{ diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index 089449be9..34000a716 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -241,6 +241,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + AIChatHit::ToggleChecklist => { + self.editor_state.chat.toggle_checklist_collapsed(); + self.mark_dirty(); + return true; + } } } } diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 7863f82b8..bf1cdf2f8 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -696,6 +696,11 @@ impl WidgetHost { self.mark_dirty(); return true; } + AIChatHit::ToggleChecklist => { + self.editor_state.chat.toggle_checklist_collapsed(); + self.mark_dirty(); + return true; + } } } }