From 22b3d89855ba99c5cdcf7390aec8033003b68d98 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 30 May 2026 05:32:12 +0800 Subject: [PATCH] fix(ai): pin chat step checklist --- .../src/widgets/ai_chat_checklist.rs | 279 ++++++++++++++++++ .../op-editor-ui/src/widgets/ai_chat_panel.rs | 43 +-- .../src/widgets/ai_chat_panel/tests.rs | 20 ++ .../src/widgets/ai_chat_transcript.rs | 7 +- crates/op-editor-ui/src/widgets/mod.rs | 2 + 5 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/ai_chat_checklist.rs diff --git a/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs b/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs new file mode 100644 index 000000000..dfb69cd7a --- /dev/null +++ b/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs @@ -0,0 +1,279 @@ +//! Fixed "Pencil it out" checklist for AI design progress. +//! +//! TS pins this process block between transcript and input instead +//! of letting step rows scroll away with assistant messages. + +use super::ai_chat_panel::{to_jian_color, PAD}; +use super::ai_chat_transcript_steps::{extract_step_blocks, ParsedStep, ParsedStepStatus}; +use crate::theme::Theme; +use crate::widgets::icons::{draw_icon, Icon}; +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; +const ITEM_H: f32 = 22.0; +const ITEM_GAP: f32 = 1.0; +const BOTTOM_PAD: f32 = 8.0; +const MAX_LIST_H: f32 = 144.0; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ChecklistItem { + pub label: String, + pub done: bool, + pub active: bool, + pub failed: bool, +} + +pub(crate) fn fixed_checklist_items(messages: &[ChatMessage]) -> Vec { + let Some(message) = messages + .iter() + .rev() + .find(|msg| msg.role == ChatRole::Assistant) + else { + return Vec::new(); + }; + + let steps: Vec = extract_step_blocks(&message.content, message.streaming) + .steps + .into_iter() + .filter(|step| !step.title.eq_ignore_ascii_case("Thinking")) + .collect(); + if steps.is_empty() { + return Vec::new(); + } + + let total = steps.len(); + let items: Vec = steps + .iter() + .enumerate() + .map(|(index, step)| { + let (done, active, failed) = item_state(step, message.streaming, index, total); + ChecklistItem { + label: step.title.clone(), + done, + active, + failed, + } + }) + .collect(); + + let completed = items.iter().filter(|item| item.done).count(); + let failed = items.iter().any(|item| item.failed); + if !message.streaming && completed == 0 && !failed { + Vec::new() + } else { + items + } +} + +pub(crate) fn fixed_checklist_height(messages: &[ChatMessage]) -> f32 { + let count = fixed_checklist_items(messages).len(); + if count == 0 { + return 0.0; + } + let list_h = (count as f32 * (ITEM_H + ITEM_GAP) - ITEM_GAP).min(MAX_LIST_H); + PROGRESS_H + HEADER_H + list_h + BOTTOM_PAD +} + +pub(crate) fn fixed_checklist_rect(panel_rect: Rect, input_h: f32, height: f32) -> Rect { + let bottom = panel_rect.origin.y + panel_rect.size.y - input_h; + Rect::xywh( + panel_rect.origin.x, + bottom - height, + panel_rect.size.x, + height, + ) +} + +pub(crate) fn paint_fixed_checklist( + cx: &mut PaintCx<'_>, + theme: &Theme, + rect: Rect, + messages: &[ChatMessage], +) { + let items = fixed_checklist_items(messages); + if items.is_empty() { + return; + } + + cx.backend.save(); + cx.backend.clip_rect(rect); + cx.backend.fill_rect(rect, with_alpha(theme.popover, 0.96)); + cx.backend.fill_rect( + Rect::xywh(rect.origin.x, rect.origin.y, rect.size.x, PROGRESS_H), + with_alpha(theme.muted, 0.55), + ); + let completed = items.iter().filter(|item| item.done).count(); + let progress = completed as f32 / items.len() as f32; + cx.backend.fill_rect( + Rect::xywh( + rect.origin.x, + rect.origin.y, + rect.size.x * progress, + PROGRESS_H, + ), + theme.primary, + ); + cx.backend.fill_rect( + Rect::xywh(rect.origin.x, rect.origin.y, rect.size.x, 1.0), + theme.border, + ); + + let header_y = rect.origin.y + PROGRESS_H; + draw_icon( + cx.backend, + Icon::Pencil, + Point2D::new(rect.origin.x + PAD, header_y + 9.0), + 13.0, + theme.muted_foreground, + 1.5, + ); + draw_label( + cx, + "Pencil it out", + 12.0, + theme.foreground, + rect.origin.x + PAD + 20.0, + header_y + 20.0, + ); + + let counter = format!("{completed}/{}", items.len()); + let counter_w = cx.backend.measure_text(&counter, 10.0); + draw_label( + cx, + &counter, + 10.0, + theme.muted_foreground, + rect.origin.x + rect.size.x - PAD - counter_w, + header_y + 19.0, + ); + + let mut y = header_y + HEADER_H; + for item in &items { + if y + ITEM_H > rect.origin.y + rect.size.y - BOTTOM_PAD { + break; + } + paint_item( + cx, + theme, + item, + rect.origin.x + PAD, + y, + rect.size.x - PAD * 2.0, + ); + y += ITEM_H + ITEM_GAP; + } + cx.backend.restore(); +} + +fn item_state( + step: &ParsedStep, + streaming: bool, + index: usize, + total: usize, +) -> (bool, bool, bool) { + match step.status { + Some(ParsedStepStatus::Done) => (true, false, false), + Some(ParsedStepStatus::Error) => (false, false, true), + Some(ParsedStepStatus::Streaming) => (false, streaming, false), + Some(ParsedStepStatus::Pending) => (false, false, false), + None => { + let done = !streaming || index + 1 < total; + let active = streaming && index + 1 == total && !done; + (done, active, false) + } + } +} + +fn paint_item(cx: &mut PaintCx<'_>, theme: &Theme, item: &ChecklistItem, x: f32, y: f32, w: f32) { + if item.active { + cx.backend.fill_round_rect( + Rect::xywh(x, y, w, ITEM_H), + 5.0, + with_alpha(theme.primary, 0.08), + ); + } + let icon_x = x + 2.0; + let icon_y = y + 5.0; + if item.done { + cx.backend.fill_oval( + Rect::xywh(icon_x, icon_y, 12.0, 12.0), + with_alpha(theme.primary, 0.18), + ); + draw_icon( + cx.backend, + Icon::Check, + Point2D::new(icon_x + 2.0, icon_y + 2.0), + 8.0, + theme.primary, + 2.0, + ); + } else if item.failed { + draw_icon( + cx.backend, + Icon::AlertTriangle, + Point2D::new(icon_x, icon_y), + 13.0, + theme.destructive, + 1.7, + ); + } else { + let color = if item.active { + theme.primary + } else { + with_alpha(theme.muted_foreground, 0.35) + }; + let r = if item.active { 4.0 } else { 3.0 }; + cx.backend.fill_oval( + Rect::xywh(icon_x + 6.0 - r, icon_y + 6.0 - r, r * 2.0, r * 2.0), + color, + ); + } + + let color = if item.done { + theme.muted_foreground + } else if item.failed { + theme.destructive + } else if item.active { + theme.foreground + } else { + with_alpha(theme.muted_foreground, 0.65) + }; + draw_label(cx, &item.label, 12.0, color, x + 24.0, y + 15.0); +} + +fn draw_label(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32, y: f32) { + let label = TextLayout::single_run( + text, + "system-ui", + size, + to_jian_color(color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text(&label, Point2D::new(x, y)); +} + +fn with_alpha(color: Color, a: f32) -> Color { + Color { a, ..color } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_checklist_uses_latest_assistant_step_status() { + let mut message = ChatMessage::assistant_streaming(); + message.content = r#" +"# + .into(); + + let items = fixed_checklist_items(std::slice::from_ref(&message)); + + assert_eq!(items.len(), 2); + assert!(items[0].done); + assert!(items[1].active); + } +} 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 c0005509c..447df863d 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -1,4 +1,7 @@ use crate::theme::Theme; +use crate::widgets::ai_chat_checklist::{ + fixed_checklist_height, fixed_checklist_rect, paint_fixed_checklist, +}; use crate::widgets::ai_chat_panel_controls::{ attachment_row_hit, paint_attachment_row, ATTACHMENT_ROW_HEIGHT, }; @@ -122,10 +125,7 @@ pub struct AIChatPlaceholder<'a> { pub id: WidgetId, pub theme: Theme, pub state: &'a ChatState, - /// Host-supplied frame timestamp (milliseconds since the host - /// started). Drives caret blink via - /// [`jian_core::anim::blink_visible`]. `0` = host hasn't - /// installed a clock yet (caret stays solid). + /// Host frame timestamp in ms; drives caret blink. pub now_ms: u64, /// Localised chrome strings — resolved at construction time /// from `Document::t` so the panel reflows when the user @@ -133,9 +133,7 @@ pub struct AIChatPlaceholder<'a> { pub label_new_chat: String, pub label_start_with_ai: String, pub label_input_placeholder: String, - /// Tip line ("select canvas elements before chatting to provide - /// context") — bottom of the empty-state body, between the example - /// cards and the separator above the input. + /// Empty-state tip line below the example cards. pub label_tip_select_elements: String, /// Chip label shown when no model is selected / discovered yet /// (`ai.noModelsConnected`). @@ -211,24 +209,21 @@ impl<'a> AIChatPlaceholder<'a> { INPUT_BASE_HEIGHT + self.attachment_row_h() } - /// Bounds of the transcript body region — between the header and - /// the input block. Shared by `paint` and `hit_test` so the - /// message layout (and its collapsible-header hit targets) line - /// up exactly. + /// Bounds of the transcript body region. fn body_rect(&self, rect: Rect) -> Rect { let body_top = rect.origin.y + HEADER_HEIGHT; - let body_bottom = rect.origin.y + rect.size.y - self.input_height() - PAD - 8.0; + let body_bottom = rect.origin.y + rect.size.y + - self.input_height() + - PAD + - 8.0 + - fixed_checklist_height(&self.state.messages); 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)), } } - /// Bounds of the model-picker dropdown — anchored just above - /// the bottom toolbar (the chip), growing upward over the - /// message list. `input_rect` is the panel's input box. The - /// height is capped at `MODEL_PICKER_MAX_H`; a taller catalog - /// scrolls inside the card rather than overflowing the screen. + /// Bounds of the model-picker dropdown. fn model_picker_rect(&self, rect: Rect, input_rect: Rect) -> Rect { let height = crate::widgets::ai_chat_model_picker::picker_view_height( &self.state.available_models, @@ -242,10 +237,7 @@ impl<'a> AIChatPlaceholder<'a> { } } - /// Public bounds of the open model-picker dropdown — `None` when - /// the picker is closed. The host uses this to route wheel + - /// cursor-move input into the picker (scroll + hover) without - /// re-deriving the panel's internal input-block geometry. + /// Public bounds of the open model-picker dropdown. pub fn model_picker_bounds(&self, rect: Rect) -> Option { if !self.model_picker_open { return None; @@ -511,6 +503,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { // Body — either messages or examples. let input_h = self.input_height(); + let checklist_h = fixed_checklist_height(&self.state.messages); if self.state.messages.is_empty() { paint_examples( cx, @@ -530,6 +523,14 @@ impl<'a> Widget for AIChatPlaceholder<'a> { self.locale, ); } + if checklist_h > 0.0 { + paint_fixed_checklist( + cx, + &self.theme, + fixed_checklist_rect(rect, input_h, checklist_h), + &self.state.messages, + ); + } // Separator hairline between body and input area // (matches the TS panel's bottom-bordered body region). 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 cd46f6240..9ebed97e1 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 @@ -169,3 +169,23 @@ fn hit_test_resolves_header_new_chat_button() { let p = Point2D::new(AI_CHAT_WIDTH - PAD - 22.0 + 9.0, 17.0); assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::NewChat)); } + +#[test] +fn body_rect_reserves_space_for_fixed_step_checklist() { + let mut s = EditorState::new(); + let mut message = op_editor_core::ChatMessage::assistant_streaming(); + message.content = + r#"Analyzing request..."# + .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 body = panel.body_rect(rect); + let legacy_bottom = rect.origin.y + rect.size.y - INPUT_BASE_HEIGHT - PAD - 8.0; + + assert!( + body.origin.y + body.size.y < legacy_bottom - 1.0, + "fixed step checklist should reserve bottom space outside transcript" + ); +} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs index 61b6acb74..d322e0367 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs @@ -18,10 +18,9 @@ use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; use op_editor_core::chat::{ChatMessage, ChatRole, ChatToolCall}; -#[path = "ai_chat_transcript_steps.rs"] -mod transcript_steps; - -use transcript_steps::{extract_step_blocks, split_design_progress, ParsedStep, ParsedStepStatus}; +use super::ai_chat_transcript_steps::{ + extract_step_blocks, split_design_progress, ParsedStep, ParsedStepStatus, +}; /// Body text size used throughout the transcript. const BODY_FONT: f32 = 12.0; diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index d98bffddf..c6c58dd30 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -99,11 +99,13 @@ pub mod agent_settings_panel; #[cfg(test)] mod agent_settings_panel_tests; pub mod agent_settings_system; +mod ai_chat_checklist; pub mod ai_chat_model_picker; pub mod ai_chat_panel; pub mod ai_chat_panel_controls; pub mod ai_chat_panel_paint; pub mod ai_chat_transcript; +pub(crate) mod ai_chat_transcript_steps; pub mod align_toolbar; pub mod color_picker; pub mod component_browser_panel;