fix(ai): show chat step details

This commit is contained in:
Fini 2026-05-30 06:21:34 +08:00
parent 9498e8d37d
commit 8b28d7e51f
3 changed files with 82 additions and 3 deletions

View file

@ -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<String>,
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<String> = 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,

View file

@ -10,6 +10,7 @@ pub(crate) enum ParsedStepStatus {
pub(crate) struct ParsedStep {
pub title: String,
pub status: Option<ParsedStepStatus>,
pub details: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -28,6 +29,7 @@ pub(crate) fn split_design_progress(thinking: &str) -> (Vec<ParsedStep>, 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<ParsedStep>, 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<String> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
}
fn parse_status(value: &str) -> Option<ParsedStepStatus> {
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()],
}]
);
}

View file

@ -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#"<step title="Validate design" status="streaming">
lint: fixed spacing
render: captured frame
</step>"#
.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");