From fdf0147dbfa5d1ac6910384e6b55b535cabda8f2 Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 31 May 2026 13:46:42 +0800 Subject: [PATCH] fix(ai): show design checklist details --- .../src/widgets/ai_chat_checklist.rs | 152 +++++++++++++++++- 1 file changed, 147 insertions(+), 5 deletions(-) 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 ac7609813..cae60654b 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs @@ -17,6 +17,8 @@ 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 DETAIL_GAP: f32 = 2.0; +const DETAIL_LINE_H: f32 = 14.0; const BOTTOM_PAD: f32 = 8.0; const MAX_LIST_H: f32 = 144.0; @@ -26,6 +28,14 @@ pub(crate) struct ChecklistItem { pub done: bool, pub active: bool, pub failed: bool, + pub details: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DetailStatus { + Done, + Pending, + Error, } pub(crate) fn fixed_checklist_items(messages: &[ChatMessage]) -> Vec { @@ -64,6 +74,7 @@ pub(crate) fn fixed_checklist_items(messages: &[ChatMessage]) -> Vec Vec f32 { - let count = fixed_checklist_items(messages).len(); + let items = fixed_checklist_items(messages); + let count = items.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); + let list_h = + items.iter().map(item_height).sum::() + (count.saturating_sub(1) as f32 * ITEM_GAP); + let list_h = list_h.min(MAX_LIST_H); PROGRESS_H + HEADER_H + list_h + BOTTOM_PAD } @@ -182,7 +196,8 @@ pub(crate) fn paint_fixed_checklist( let mut y = header_y + HEADER_H; for item in &items { - if y + ITEM_H > rect.origin.y + rect.size.y - BOTTOM_PAD { + let height = item_height(item); + if y + height > rect.origin.y + rect.size.y - BOTTOM_PAD { break; } paint_item( @@ -193,11 +208,19 @@ pub(crate) fn paint_fixed_checklist( y, rect.size.x - PAD * 2.0, ); - y += ITEM_H + ITEM_GAP; + y += height + ITEM_GAP; } cx.backend.restore(); } +fn item_height(item: &ChecklistItem) -> f32 { + if item.details.is_empty() { + ITEM_H + } else { + ITEM_H + DETAIL_GAP + item.details.len() as f32 * DETAIL_LINE_H + } +} + fn item_state( step: &ParsedStep, streaming: bool, @@ -220,7 +243,7 @@ fn item_state( 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), + Rect::xywh(x, y, w, item_height(item)), 5.0, with_alpha(theme.primary, 0.08), ); @@ -272,6 +295,75 @@ fn paint_item(cx: &mut PaintCx<'_>, theme: &Theme, item: &ChecklistItem, x: f32, with_alpha(theme.muted_foreground, 0.65) }; draw_label(cx, &item.label, 12.0, color, x + 24.0, y + 15.0); + if !item.details.is_empty() { + let mut baseline = y + ITEM_H + DETAIL_GAP + 10.0; + for detail in &item.details { + let (status, text) = parse_detail_status(detail); + let text_x = if let Some(status) = status { + paint_detail_status(cx, theme, status, x + 25.0, baseline - 8.5); + x + 39.0 + } else { + x + 24.0 + }; + draw_label( + cx, + text, + 10.0, + with_alpha(theme.muted_foreground, 0.65), + text_x, + baseline, + ); + baseline += DETAIL_LINE_H; + } + } +} + +fn parse_detail_status(line: &str) -> (Option, &str) { + for (prefix, status) in [ + ("[done]", DetailStatus::Done), + ("[pending]", DetailStatus::Pending), + ("[error]", DetailStatus::Error), + ] { + if let Some(rest) = line.strip_prefix(prefix) { + return (Some(status), rest.trim_start()); + } + } + (None, line) +} + +fn paint_detail_status(cx: &mut PaintCx<'_>, theme: &Theme, status: DetailStatus, x: f32, y: f32) { + match status { + DetailStatus::Done => { + cx.backend.fill_oval( + Rect::xywh(x, y, 10.0, 10.0), + with_alpha(theme.primary, 0.16), + ); + draw_icon( + cx.backend, + Icon::Check, + Point2D::new(x + 2.0, y + 2.0), + 6.0, + theme.primary, + 2.0, + ); + } + DetailStatus::Pending => { + cx.backend.fill_oval( + Rect::xywh(x + 3.0, y + 3.0, 4.0, 4.0), + with_alpha(theme.primary, 0.7), + ); + } + DetailStatus::Error => { + draw_icon( + cx.backend, + Icon::AlertTriangle, + Point2D::new(x, y - 1.0), + 11.0, + theme.destructive, + 1.5, + ); + } + } } fn draw_label(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32, y: f32) { @@ -334,4 +426,54 @@ mod tests { assert!(expanded > collapsed); assert_eq!(collapsed, PROGRESS_H + HEADER_H); } + + #[test] + fn fixed_checklist_keeps_step_detail_lines() { + let mut message = ChatMessage::assistant_streaming(); + message.content = r#" +[done] Checked constraints +[pending] Choose layout +"# + .into(); + + let items = fixed_checklist_items(std::slice::from_ref(&message)); + + assert_eq!( + items[0].details, + vec![ + "[done] Checked constraints".to_string(), + "[pending] Choose layout".to_string() + ] + ); + } + + #[test] + fn fixed_checklist_height_grows_for_step_detail_lines() { + let mut plain = ChatMessage::assistant_streaming(); + plain.content = r#""#.into(); + let mut detailed = ChatMessage::assistant_streaming(); + detailed.content = r#" +Checked constraints +Choose layout +"# + .into(); + + let plain_h = fixed_checklist_height(&[plain], false); + let detailed_h = fixed_checklist_height(&[detailed], false); + + assert!(detailed_h > plain_h); + } + + #[test] + fn parse_detail_status_strips_ts_prefixes() { + assert_eq!( + parse_detail_status("[done] Checked constraints"), + (Some(DetailStatus::Done), "Checked constraints") + ); + assert_eq!( + parse_detail_status("[pending] Choose layout"), + (Some(DetailStatus::Pending), "Choose layout") + ); + assert_eq!(parse_detail_status("Plain detail"), (None, "Plain detail")); + } }