fix(ai): pin chat step checklist

This commit is contained in:
Fini 2026-05-30 05:32:12 +08:00
parent 32a4134336
commit 22b3d89855
5 changed files with 326 additions and 25 deletions

View file

@ -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<ChecklistItem> {
let Some(message) = messages
.iter()
.rev()
.find(|msg| msg.role == ChatRole::Assistant)
else {
return Vec::new();
};
let steps: Vec<ParsedStep> = 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<ChecklistItem> = 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#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.into();
let items = fixed_checklist_items(std::slice::from_ref(&message));
assert_eq!(items.len(), 2);
assert!(items[0].done);
assert!(items[1].active);
}
}

View file

@ -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<Rect> {
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).

View file

@ -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#"<step title="Checking guidelines" status="streaming">Analyzing request...</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 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"
);
}

View file

@ -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;

View file

@ -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;