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 8ffe6b945..92a356228 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs @@ -37,6 +37,10 @@ const MSG_GAP: f32 = 10.0; const SUB_GAP: f32 = 4.0; /// Height of one compact design-progress step row. const ACTION_STEP_H: f32 = 28.0; +/// Height of one detail line under a progress step. +const ACTION_DETAIL_LINE_H: f32 = 14.0; +/// Gap between the progress title row and detail lines. +const ACTION_DETAIL_GAP: f32 = 4.0; /// Vertical gap between compact design-progress rows. const ACTION_STEP_GAP: f32 = 4.0; /// Side length of an image thumbnail box. @@ -75,6 +79,7 @@ pub(crate) struct Collapsible { pub(crate) struct ActionStep { pub rect: Rect, pub label: String, + pub details: Vec, pub done: bool, pub active: bool, pub failed: bool, @@ -209,14 +214,21 @@ fn build_item( let total_steps = progress_steps.len(); for (i, step) in progress_steps.iter().enumerate() { let (done, active, failed) = step_state(step, msg.streaming, i, total_steps); + let details: Vec = step + .details + .iter() + .flat_map(|line| wrap_units(line, budget.saturating_sub(4))) + .collect(); + let step_h = action_step_height(details.len()); steps.push(ActionStep { - rect: Rect::xywh(x, y, bubble_w, ACTION_STEP_H), + rect: Rect::xywh(x, y, bubble_w, step_h), label: step.title.clone(), + details, done, active, failed, }); - y += ACTION_STEP_H + ACTION_STEP_GAP; + y += step_h + ACTION_STEP_GAP; } let thinking = build_collapsible( @@ -303,6 +315,14 @@ fn build_item( ) } +fn action_step_height(detail_count: usize) -> f32 { + if detail_count == 0 { + ACTION_STEP_H + } else { + ACTION_STEP_H + ACTION_DETAIL_GAP + detail_count as f32 * ACTION_DETAIL_LINE_H + } +} + /// Lay out the tail of `messages` that fits inside `body_rect`, /// top-aligned. Each item carries absolute rects ready for paint and /// hit-test. @@ -523,6 +543,23 @@ fn paint_action_step(cx: &mut PaintCx<'_>, theme: &Theme, step: &ActionStep) { 11.0, label_color, ); + if !step.details.is_empty() { + cx.backend.save(); + cx.backend.clip_rect(step.rect); + let mut baseline = step.rect.origin.y + ACTION_STEP_H + ACTION_DETAIL_GAP + 9.0; + for line in &step.details { + draw_line( + cx, + line, + step.rect.origin.x + 32.0, + baseline, + 10.0, + theme.muted_foreground, + ); + baseline += ACTION_DETAIL_LINE_H; + } + cx.backend.restore(); + } } /// Paint a collapsible block — header row (chevron + label) plus, diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript_steps.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript_steps.rs index d6c306604..f2b022eb4 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript_steps.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript_steps.rs @@ -10,6 +10,7 @@ pub(crate) enum ParsedStepStatus { pub(crate) struct ParsedStep { pub title: String, pub status: Option, + pub details: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -28,6 +29,7 @@ pub(crate) fn split_design_progress(thinking: &str) -> (Vec, String) steps.push(ParsedStep { title: label.to_string(), status: None, + details: Vec::new(), }); } } else if !trimmed.is_empty() { @@ -81,7 +83,7 @@ fn finish_extraction(steps: Vec, visible: String) -> StepExtraction } } -fn parsed_step(attrs: &str, _content: &str, partial: bool) -> ParsedStep { +fn parsed_step(attrs: &str, content: &str, partial: bool) -> ParsedStep { let default_title = if partial { "Design" } else { "Processing" }; ParsedStep { title: attr_value(attrs, "title") @@ -89,9 +91,19 @@ fn parsed_step(attrs: &str, _content: &str, partial: bool) -> ParsedStep { .filter(|s| !s.is_empty()) .unwrap_or_else(|| default_title.to_string()), status: attr_value(attrs, "status").and_then(|s| parse_status(s.trim())), + details: step_details(content), } } +fn step_details(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + fn parse_status(value: &str) -> Option { match value.to_ascii_lowercase().as_str() { "pending" => Some(ParsedStepStatus::Pending), @@ -163,6 +175,7 @@ mod tests { vec![ParsedStep { title: "Check".into(), status: Some(ParsedStepStatus::Done), + details: vec!["ok".into()], }] ); } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs index 3804ef9a8..da95d8fab 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs @@ -154,6 +154,35 @@ fn step_tag_content_renders_as_progress_not_raw_bubble() { ); } +#[test] +fn step_tag_content_surfaces_as_progress_details() { + let mut message = ChatMessage::assistant_streaming(); + message.content = r#" +lint: fixed spacing +render: captured frame +"# + .into(); + + let items = build_transcript( + std::slice::from_ref(&message), + body(), + op_editor_core::Locale::EnUs, + ); + + assert_eq!(items[0].steps.len(), 1); + assert_eq!( + items[0].steps[0].details, + vec![ + "lint: fixed spacing".to_string(), + "render: captured frame".to_string() + ] + ); + assert!( + items[0].steps[0].rect.size.y > 28.0, + "step details should reserve space instead of being dropped" + ); +} + #[test] fn tool_calls_block_header_label_counts_the_calls() { let mut m = ChatMessage::assistant("done");