fix(ai): pin chat step checklist
This commit is contained in:
parent
32a4134336
commit
22b3d89855
279
crates/op-editor-ui/src/widgets/ai_chat_checklist.rs
Normal file
279
crates/op-editor-ui/src/widgets/ai_chat_checklist.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue