fix(ai): collapse design checklist

This commit is contained in:
Fini 2026-05-31 13:36:31 +08:00
parent 621e71bc39
commit 90e61fb2fa
9 changed files with 194 additions and 62 deletions

View file

@ -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);

View file

@ -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<ChecklistIt
}
}
pub(crate) fn fixed_checklist_height(messages: &[ChatMessage]) -> 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#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.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);
}
}

View file

@ -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,
}

View file

@ -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,
);
}

View file

@ -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#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.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#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.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)>,

View file

@ -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;

View file

@ -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::{

View file

@ -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;
}
}
}
}

View file

@ -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;
}
}
}
}