mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
fix(ai): collapse design checklist
This commit is contained in:
parent
621e71bc39
commit
90e61fb2fa
9 changed files with 194 additions and 62 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
crates/op-editor-ui/src/widgets/ai_chat_hit.rs
Normal file
54
crates/op-editor-ui/src/widgets/ai_chat_hit.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue