refactor(ui): retire the fixed 'Pencil it out' checklist panel

The pinned checklist duplicated what the transcript already renders
inline (action steps, subtask cards, verb chips, agent narration - the
Pencil-style reading flow), while eating a third of the panel's height.
The transcript is now the single progress surface; the checklist
widget, its hit/scroll wiring on both hosts and its ChatState fields
are removed.
This commit is contained in:
Fini 2026-07-12 03:34:36 +08:00
parent 2586f06cc3
commit 075b1f25eb
12 changed files with 2 additions and 1171 deletions

View file

@ -359,15 +359,6 @@ pub struct ChatState {
/// the canvas region with a small inset, mirroring the TS app's
/// expanded panel.
pub maximized: bool,
/// Collapsed state for the fixed "Pencil it out" design checklist
/// pinned above the input, mirroring the TS checklist header.
pub checklist_collapsed: bool,
/// Indices of fixed-checklist rows whose detail sub-lines are
/// expanded. Absent = collapsed (the default). Mirrors the
/// per-index override model used by design-block cards.
pub checklist_item_expanded: std::collections::BTreeSet<usize>,
/// Vertical scroll offset inside the fixed design checklist rows.
pub checklist_scroll: jian_core::scroll::ScrollState,
/// Vertical scroll offset (px from the conversation top) of the
/// transcript message list. Clamped to `[0, content_height - body]`
/// by the host on wheel; ignored while [`transcript_pinned`] holds.
@ -457,9 +448,6 @@ impl Default for ChatState {
panel_position: None,
collapsed: false,
maximized: false,
checklist_collapsed: false,
checklist_item_expanded: std::collections::BTreeSet::new(),
checklist_scroll: Default::default(),
transcript_scroll: Default::default(),
transcript_pinned: true,
pending_send: None,
@ -583,7 +571,6 @@ impl ChatState {
assistant_msg.agent_name = agent_name;
self.messages.push(assistant_msg);
self.input.set_text("");
self.checklist_scroll.offset = 0.0;
// Jump to the bottom so the new turn's reply is visible as it
// streams, even if the user had scrolled up in the prior turn.
self.transcript_pinned = true;
@ -634,7 +621,6 @@ impl ChatState {
self.pending_stop_chat = false;
self.pending_copy_text = None;
self.transcript_selection = None;
self.checklist_scroll.offset = 0.0;
self.transcript_pinned = true;
self.transcript_scroll.offset = 0.0;
self.pending_attachments.clear();
@ -819,22 +805,6 @@ impl ChatState {
msg.action_step_expanded_overrides[step_idx] = Some(expanded);
}
/// Flip the fixed design-checklist panel state.
pub fn toggle_checklist_collapsed(&mut self) {
self.checklist_collapsed = !self.checklist_collapsed;
if self.checklist_collapsed {
self.checklist_scroll.offset = 0.0;
}
}
/// Toggle a single fixed-checklist row's detail expansion by index.
/// Absent = collapsed (default); present = expanded.
pub fn set_checklist_item_expanded(&mut self, item_idx: usize) {
if !self.checklist_item_expanded.remove(&item_idx) {
self.checklist_item_expanded.insert(item_idx);
}
}
/// Advance the thinking-mode selector one step:
/// Adaptive → Disabled → Enabled → Adaptive.
pub fn cycle_thinking_mode(&mut self) {
@ -1303,39 +1273,6 @@ mod tests {
assert_eq!(chat.pending_copy_text.as_deref(), Some("json"));
}
#[test]
fn set_checklist_item_expanded_toggles_membership() {
let mut chat = ChatState::default();
assert!(chat.checklist_item_expanded.is_empty());
chat.set_checklist_item_expanded(2);
assert!(chat.checklist_item_expanded.contains(&2));
chat.set_checklist_item_expanded(2);
assert!(!chat.checklist_item_expanded.contains(&2));
}
#[test]
fn toggle_checklist_collapsed_flips_panel_checklist_flag() {
let mut chat = ChatState::default();
assert!(!chat.checklist_collapsed);
chat.toggle_checklist_collapsed();
assert!(chat.checklist_collapsed);
chat.toggle_checklist_collapsed();
assert!(!chat.checklist_collapsed);
}
#[test]
fn checklist_scroll_uses_scroll_state() {
let mut chat = ChatState::default();
chat.checklist_scroll.offset = 20.0;
assert_eq!(chat.checklist_scroll.offset, 20.0);
}
#[test]
fn nearest_anchor_picks_corner() {
let p = crate::render_backend::Point2D::new(10.0, 10.0);

View file

@ -1,791 +0,0 @@
//! 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::PAD;
use super::ai_chat_transcript_design::extract_design_json_blocks;
use super::ai_chat_transcript_steps::{
extract_step_blocks, split_design_progress, 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, ChatState};
pub(crate) const PROGRESS_H: f32 = 2.0;
pub(crate) const HEADER_H: f32 = 32.0;
const ITEM_H: f32 = 22.0;
pub(crate) 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;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChecklistItem {
pub label: String,
pub done: bool,
pub active: bool,
pub failed: bool,
pub details: Vec<String>,
pub expanded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetailStatus {
Done,
Pending,
Error,
}
#[derive(Debug, Clone, Copy)]
struct ChecklistProgress {
streaming: bool,
total: usize,
has_explicit_status: bool,
has_terminal_result: bool,
json_block_count: usize,
use_progress_position_fallback: bool,
}
pub(crate) fn fixed_checklist_items(chat: &ChatState) -> Vec<ChecklistItem> {
let messages = &chat.messages;
let Some(message) = messages
.iter()
.rev()
.find(|msg| msg.role == ChatRole::Assistant)
else {
return Vec::new();
};
let mut steps: Vec<ParsedStep> = extract_step_blocks(&message.content, message.streaming)
.steps
.into_iter()
.filter(|step| !step.title.eq_ignore_ascii_case("Thinking"))
.collect();
let mut use_progress_position_fallback = false;
if steps.is_empty() {
steps = split_design_progress(&message.thinking)
.0
.into_iter()
.filter(|step| !step.title.eq_ignore_ascii_case("Thinking"))
.collect();
use_progress_position_fallback = !steps.is_empty();
}
if steps.is_empty() {
let tool_items = loop_tool_call_items(chat, message);
if !tool_items.is_empty() {
return tool_items;
}
return Vec::new();
}
let json_block_count = extract_design_json_blocks(&message.content, message.streaming)
.blocks
.len();
let is_applied = message.content.contains('✅')
|| message.content.contains("<!-- APPLIED -->")
|| message.content.contains("[done] Applied");
let has_error = message.content.to_ascii_lowercase().contains("**error:**");
let has_explicit_status = steps.iter().any(|step| step.status.is_some());
let has_terminal_result =
!message.streaming && !has_error && (is_applied || json_block_count > 0);
let progress = ChecklistProgress {
streaming: message.streaming,
total: steps.len(),
has_explicit_status,
has_terminal_result,
json_block_count,
use_progress_position_fallback,
};
let items: Vec<ChecklistItem> = steps
.iter()
.enumerate()
.map(|(index, step)| {
let (done, active, failed) = item_state(step, index, progress);
ChecklistItem {
label: step.title.clone(),
done,
active,
failed,
details: step.details.clone(),
expanded: chat.checklist_item_expanded.contains(&index),
}
})
.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
}
}
fn loop_tool_call_items(chat: &ChatState, message: &ChatMessage) -> Vec<ChecklistItem> {
if chat.agents_running.1 == 0 || message.tool_calls.is_empty() {
return Vec::new();
}
message
.tool_calls
.iter()
.enumerate()
.map(|(index, call)| {
let status = tool_call_status(&call.args);
let done = status.as_deref() == Some("done");
let failed = status.as_deref() == Some("error");
let active = status.as_deref() == Some("running")
|| (status.is_none() && message.streaming && index + 1 == message.tool_calls.len());
ChecklistItem {
label: tool_call_label(&call.name),
done,
active,
failed,
details: Vec::new(),
expanded: false,
}
})
.collect()
}
fn tool_call_status(args: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(args)
.ok()?
.get("status")?
.as_str()
.map(str::to_string)
}
fn tool_call_label(name: &str) -> String {
match name {
"get_editor_state" => "Read canvas state",
"get_style_guide_tags" | "get_style_guide" => "Load style guide",
"get_variables" => "Read variables",
"batch_get" => "Read components",
"snapshot_layout" => "Inspect layout",
"find_empty_space" => "Find placement",
"emit_elements" => "Emit elements",
"batch_design" => "Apply design batch",
"get_screenshot" => "Verify screenshot",
"spawn_agents" => "Spawn section agents",
other => other,
}
.to_string()
}
pub(crate) fn fixed_checklist_height(chat: &ChatState, collapsed: bool) -> f32 {
let items = fixed_checklist_items(chat);
let count = items.len();
if count == 0 {
return 0.0;
}
if collapsed {
return PROGRESS_H + HEADER_H;
}
let list_h = item_list_height(&items).min(MAX_LIST_H);
PROGRESS_H + HEADER_H + list_h + BOTTOM_PAD
}
pub(crate) fn fixed_checklist_content_height(chat: &ChatState) -> f32 {
let items = fixed_checklist_items(chat);
item_list_height(&items)
}
pub(crate) fn fixed_checklist_max_scroll(chat: &ChatState, collapsed: bool) -> f32 {
if collapsed {
return 0.0;
}
let content_h = fixed_checklist_content_height(chat);
(content_h - content_h.min(MAX_LIST_H)).max(0.0)
}
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 fixed_checklist_list_rect(rect: Rect) -> Rect {
let top = rect.origin.y + PROGRESS_H + HEADER_H;
let bottom = rect.origin.y + rect.size.y - BOTTOM_PAD;
Rect::xywh(rect.origin.x, top, rect.size.x, (bottom - top).max(0.0))
}
pub(crate) fn paint_fixed_checklist(
cx: &mut PaintCx<'_>,
theme: &Theme,
rect: Rect,
chat: &ChatState,
collapsed: bool,
scroll: f32,
) {
let items = fixed_checklist_items(chat);
if items.is_empty() {
return;
}
cx.backend.save();
cx.backend.clip_rect(rect);
cx.backend.fill_rect(rect, (theme.popover).with_alpha(0.96));
cx.backend.fill_rect(
Rect::xywh(rect.origin.x, rect.origin.y, rect.size.x, PROGRESS_H),
(theme.muted).with_alpha(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 - 20.0,
header_y + 19.0,
);
draw_icon(
cx.backend,
if collapsed {
Icon::ChevronDown
} else {
Icon::ChevronUp
},
Point2D::new(rect.origin.x + rect.size.x - PAD - 13.0, header_y + 10.0),
12.0,
theme.muted_foreground,
1.4,
);
if collapsed {
cx.backend.restore();
return;
}
let list_rect = fixed_checklist_list_rect(rect);
let list_bottom = list_rect.origin.y + list_rect.size.y;
let scroll = scroll.clamp(0.0, fixed_checklist_max_scroll(chat, collapsed));
cx.backend.save();
cx.backend.clip_rect(list_rect);
let mut y = list_rect.origin.y - scroll;
for item in &items {
let height = item_height(item);
if y + height >= list_rect.origin.y && y <= list_bottom {
paint_item(
cx,
theme,
item,
rect.origin.x + PAD,
y,
rect.size.x - PAD * 2.0,
);
}
y += height + ITEM_GAP;
}
cx.backend.restore();
cx.backend.restore();
}
fn item_list_height(items: &[ChecklistItem]) -> f32 {
if items.is_empty() {
0.0
} else {
items.iter().map(item_height).sum::<f32>()
+ (items.len().saturating_sub(1) as f32 * ITEM_GAP)
}
}
fn item_height(item: &ChecklistItem) -> f32 {
if item.details.is_empty() || !item.expanded {
ITEM_H
} else {
ITEM_H + DETAIL_GAP + item.details.len() as f32 * DETAIL_LINE_H
}
}
/// Public alias so the hit-test walker can stay in sync with paint
/// without duplicating the height formula.
pub(crate) fn checklist_item_height(item: &ChecklistItem) -> f32 {
item_height(item)
}
pub(crate) fn checklist_item_chevron_rect(item_x: f32, item_y: f32, item_w: f32) -> Rect {
// Right-aligned chevron in the summary row (ITEM_H tall band).
Rect::xywh(item_x + item_w - 16.0, item_y + 4.0, 14.0, 14.0)
}
fn item_state(step: &ParsedStep, index: usize, progress: ChecklistProgress) -> (bool, bool, bool) {
if progress.has_explicit_status {
return match step.status {
Some(ParsedStepStatus::Done) => (true, false, false),
Some(ParsedStepStatus::Error) => (false, false, true),
Some(ParsedStepStatus::Streaming) => (false, progress.streaming, false),
Some(ParsedStepStatus::Pending) | None => (false, false, false),
};
}
if progress.has_terminal_result {
return (true, false, false);
}
if progress.use_progress_position_fallback {
let done = !progress.streaming || index + 1 < progress.total;
let active = progress.streaming && index + 1 == progress.total && !done;
return (done, active, false);
}
let done = index < progress.json_block_count;
let active =
progress.streaming && !done && index == progress.json_block_count && index < progress.total;
(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_height(item)),
5.0,
(theme.primary).with_alpha(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),
(theme.primary).with_alpha(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 {
(theme.muted_foreground).with_alpha(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 {
(theme.muted_foreground).with_alpha(0.65)
};
let label_x = x + 24.0;
let chevron_reserve = if item.details.is_empty() { 0.0 } else { 18.0 };
let label_w = (w - 24.0 - chevron_reserve - 2.0).max(0.0);
let label = summary_label_for_width(&item.label, label_w, |s| cx.backend.measure_text(s, 12.0));
// Hard-clip the label to its budget. `measure_text` is a char-width
// heuristic that under-counts `·`-separated metadata, so an ellipsized
// label can still paint past `label_w` into the right-edge chevron. The
// clip is the guarantee; the ellipsis is the nicety.
cx.backend.save();
cx.backend
.clip_rect(Rect::xywh(label_x, y, label_w, ITEM_H));
draw_label(cx, &label, 12.0, color, label_x, y + 15.0);
cx.backend.restore();
if !item.details.is_empty() {
let chevron = checklist_item_chevron_rect(x, y, w);
draw_icon(
cx.backend,
if item.expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
},
Point2D::new(chevron.origin.x, chevron.origin.y),
12.0,
(theme.muted_foreground).with_alpha(0.7),
1.4,
);
}
if !item.details.is_empty() && item.expanded {
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
};
let detail_w = (x + w - text_x - 4.0).max(0.0);
let detail =
summary_label_for_width(text, detail_w, |s| cx.backend.measure_text(s, 10.0));
draw_label(
cx,
&detail,
10.0,
(theme.muted_foreground).with_alpha(0.65),
text_x,
baseline,
);
baseline += DETAIL_LINE_H;
}
}
}
fn summary_label_for_width(label: &str, max_w: f32, measure: impl FnMut(&str) -> f32) -> String {
crate::util::ellipsize_to_width(label, max_w, measure)
}
fn parse_detail_status(line: &str) -> (Option<DetailStatus>, &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),
(theme.primary).with_alpha(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),
(theme.primary).with_alpha(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) {
let label = TextLayout::single_run(
text,
"system-ui",
size,
(color).to_jian(),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(&label, Point2D::new(x, y));
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)] // test fixtures build ChatState then set messages
mod tests {
use super::*;
use op_editor_core::chat::{ChatMessage, ChatState};
#[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 mut chat = ChatState::default();
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
assert_eq!(items.len(), 2);
assert!(items[0].done);
assert!(items[1].active);
}
#[test]
fn fixed_checklist_fallback_starts_at_first_step_until_design_json_streams() {
let mut message = ChatMessage::assistant_streaming();
message.content = r#"<step title="Plan"></step>
<step title="Draw"></step>"#
.into();
let mut chat = ChatState::default();
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
assert_eq!(items.len(), 2);
assert!(
items[0].active,
"TS keeps step[0] active before any JSON block"
);
assert!(!items[0].done);
assert!(!items[1].active);
assert!(!items[1].done);
}
#[test]
fn fixed_checklist_hides_terminal_plan_without_design_result_like_ts() {
let message = ChatMessage::assistant(
r#"<step title="Plan"></step>
<step title="Draw"></step>"#,
);
let mut chat = ChatState::default();
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
assert!(
items.is_empty(),
"TS hides the checklist after a non-streaming turn with no applied or JSON result"
);
}
#[test]
fn fixed_checklist_uses_design_session_thinking_progress() {
let mut message = ChatMessage::assistant_streaming();
message.thinking = "• Planning…\n• Subtask `hero` — Hero section".into();
let mut chat = ChatState::default();
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
assert_eq!(items.len(), 2);
assert!(items[0].done);
assert!(items[1].active);
assert_eq!(items[1].label, "Subtask `hero` — Hero section");
}
#[test]
fn fixed_checklist_uses_loop_tool_call_sequence_when_no_plan_steps() {
let mut message = ChatMessage::assistant_streaming();
message.tool_calls = vec![
op_editor_core::ChatToolCall {
name: "get_editor_state".into(),
args: r#"{"status":"done"}"#.into(),
},
op_editor_core::ChatToolCall {
name: "batch_design".into(),
args: r#"{"status":"running"}"#.into(),
},
];
let mut chat = ChatState::default();
chat.agents_running = (1, 1);
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "Read canvas state");
assert!(items[0].done);
assert_eq!(items[1].label, "Apply design batch");
assert!(items[1].active);
}
#[test]
fn checklist_summary_label_is_ellipsized_to_available_width() {
let label = "Subtask `dumpling-card` · 11 skills · 7355/8000 tok · 27 dropped";
let measure = |s: &str| s.chars().count() as f32 * 7.0;
let out = summary_label_for_width(label, 120.0, measure);
assert!(
out.ends_with('…'),
"overflowing label should visibly truncate: {out}"
);
assert!(
measure(&out) <= 120.0,
"truncated checklist label must fit the row budget: {out}"
);
}
#[test]
fn fixed_checklist_height_omits_step_rows_when_collapsed() {
let mut message = ChatMessage::assistant_streaming();
message.content = r#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.into();
let mut chat = ChatState::default();
chat.messages = vec![message];
let expanded = fixed_checklist_height(&chat, false);
let collapsed = fixed_checklist_height(&chat, true);
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#"<step title="Plan" status="done">
[done] Checked constraints
[pending] Choose layout
</step>"#
.into();
let mut chat = ChatState::default();
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
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#"<step title="Plan" status="done"></step>"#.into();
let mut detailed = ChatMessage::assistant_streaming();
detailed.content = r#"<step title="Plan" status="done">
Checked constraints
Choose layout
</step>"#
.into();
let mut chat_plain = ChatState::default();
chat_plain.messages = vec![plain];
let mut chat_detailed = ChatState::default();
chat_detailed.messages = vec![detailed];
// Items with details are collapsed by default, so heights match unless expanded.
// This test verifies the detail lines exist on the item.
let items = fixed_checklist_items(&chat_detailed);
assert!(!items[0].details.is_empty());
// Expand the item to see the height grow.
chat_detailed.set_checklist_item_expanded(0);
let plain_h = fixed_checklist_height(&chat_plain, false);
let detailed_h = fixed_checklist_height(&chat_detailed, false);
assert!(detailed_h > plain_h);
}
#[test]
fn fixed_checklist_reports_scroll_overflow_for_many_steps() {
let mut message = ChatMessage::assistant_streaming();
message.content = (0..11)
.map(|idx| format!(r#"<step title="Task {idx}" status="done"></step>"#))
.collect::<Vec<_>>()
.join("\n");
let mut chat = ChatState::default();
chat.messages = vec![message];
assert!(fixed_checklist_max_scroll(&chat, false) > 0.0);
assert_eq!(fixed_checklist_max_scroll(&chat, true), 0.0);
}
#[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"));
}
#[test]
fn collapsed_item_hides_detail_lines_until_expanded() {
let mut chat = ChatState::default();
let mut message = ChatMessage::assistant_streaming();
message.thinking =
"• Subtask `header` — Header\n ▸ skills: cjk-typography, mobile-app".into();
chat.messages = vec![message];
let pre = fixed_checklist_items(&chat);
assert!(!pre[0].expanded);
chat.set_checklist_item_expanded(0);
let post = fixed_checklist_items(&chat);
assert!(post[0].expanded);
let mut chat2 = ChatState::default();
let mut message2 = ChatMessage::assistant_streaming();
message2.thinking =
"• Subtask `header` — Header\n ▸ skills: cjk-typography, mobile-app".into();
chat2.messages = vec![message2];
let collapsed_h = fixed_checklist_height(&chat2, false);
chat2.set_checklist_item_expanded(0);
let expanded_h = fixed_checklist_height(&chat2, false);
assert!(
expanded_h > collapsed_h,
"expanding a row's details must grow the list"
);
}
}

View file

@ -87,12 +87,6 @@ pub enum AIChatHit {
ApplyDesignBlock(usize, String),
/// Press on selectable transcript text.
SelectTranscriptText(usize, usize),
/// Click on the fixed "Pencil it out" checklist header — host
/// toggles the checklist body between expanded and collapsed.
ToggleChecklist,
/// Click on a checklist row's detail-expand chevron — host toggles
/// `ChatState::set_checklist_item_expanded` for that row index.
ToggleChecklistItem(usize),
/// Click on tab `i`'s body (but not its × close button) — host
/// calls `state.chat.switch_to(i)`. Wired in MT.3.
SwitchTab(usize),

View file

@ -1,7 +1,4 @@
use crate::theme::Theme;
use crate::widgets::ai_chat_checklist::{
fixed_checklist_height, fixed_checklist_rect, paint_fixed_checklist,
};
pub(crate) use crate::widgets::ai_chat_panel_controls::chat_neutral_feedback_color;
// Re-exported for paint tests that verify hover tint colours.
#[cfg(test)]
@ -335,11 +332,8 @@ impl<'a> AIChatPlaceholder<'a> {
pub fn body_rect(&self, rect: Rect) -> Rect {
let body_top = rect.origin.y + HEADER_HEIGHT + 14.0; // gap before first bubble
let body_bottom = rect.origin.y + rect.size.y
- self.input_height_for_rect(rect)
- PAD
- 8.0
- fixed_checklist_height(self.state, self.state.checklist_collapsed);
let body_bottom =
rect.origin.y + rect.size.y - self.input_height_for_rect(rect) - PAD - 8.0;
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)),
@ -728,7 +722,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
);
// Body — either messages or examples.
let checklist_h = fixed_checklist_height(self.state, self.state.checklist_collapsed);
if self.state.messages.is_empty() {
paint_examples(
cx,
@ -773,16 +766,6 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
scroll_offset,
);
}
if checklist_h > 0.0 {
paint_fixed_checklist(
cx,
&self.theme,
fixed_checklist_rect(rect, input_h, checklist_h),
self.state,
self.state.checklist_collapsed,
self.state.checklist_scroll.offset,
);
}
let input_block_rect = self.input_rect(rect);
paint_selection_chip(cx, &self.theme, self, input_block_rect);
let selection_h = self.selection_chip_row_h();

View file

@ -6,90 +6,6 @@ use super::tests::{has_fill_rect, toolbar_center_y, PanelPaintBackend};
use super::*;
use crate::widgets::ai_chat_hit::AIChatHit;
#[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"
);
}
#[test]
fn default_height_preserves_transcript_space_above_full_checklist() {
let mut s = EditorState::new();
let mut message = op_editor_core::ChatMessage::assistant_streaming();
message.content = (0..17)
.map(|idx| {
format!(r#"<step title="Subtask {idx}" status="done">Generated section {idx}</step>"#)
})
.collect::<Vec<_>>()
.join("\n");
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);
assert!(
body.size.y >= 150.0,
"default chat panel height should leave room for prior chat above the pinned checklist, got {}",
body.size.y
);
}
#[test]
fn body_rect_reserves_less_space_when_fixed_step_checklist_collapsed() {
let mut expanded_state = EditorState::new();
let mut message = op_editor_core::ChatMessage::assistant_streaming();
message.content = r#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></step>"#
.into();
expanded_state.chat.messages.push(message.clone());
let mut collapsed_state = EditorState::new();
collapsed_state.chat.messages.push(message);
collapsed_state.chat.checklist_collapsed = true;
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
let expanded = AIChatPlaceholder::from_editor(&expanded_state).body_rect(rect);
let collapsed = AIChatPlaceholder::from_editor(&collapsed_state).body_rect(rect);
assert!(collapsed.size.y > expanded.size.y);
}
#[test]
fn hit_test_resolves_fixed_checklist_header_toggle() {
let mut s = EditorState::new();
let mut message = op_editor_core::ChatMessage::assistant_streaming();
message.content = r#"<step title="Plan" status="done"></step>
<step title="Draw" status="streaming"></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 checklist_h = fixed_checklist_height(&s.chat, s.chat.checklist_collapsed);
let checklist = fixed_checklist_rect(rect, INPUT_BASE_HEIGHT, checklist_h);
let p = Point2D::new(
checklist.origin.x + checklist.size.x / 2.0,
checklist.origin.y + 2.0 + 32.0 / 2.0,
);
assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::ToggleChecklist));
}
#[test]
fn hit_test_resolves_individual_tool_card_header_toggle() {
let mut s = EditorState::new();
@ -575,31 +491,3 @@ fn tab_switch_before_paint_yields_no_cursor_hint_hit() {
"post-switch hint reads None until the new tab's first paint"
);
}
#[test]
fn hit_test_resolves_checklist_item_chevron() {
use crate::widgets::ai_chat_checklist::{
checklist_item_chevron_rect, fixed_checklist_list_rect,
};
use op_editor_core::ChatMessage;
let mut s = EditorState::new();
let mut msg = ChatMessage::assistant_streaming();
msg.thinking = "• Subtask `header` — Header\n ▸ skills: cjk-typography".into();
s.chat.messages = vec![msg];
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
let checklist_h = fixed_checklist_height(&s.chat, s.chat.checklist_collapsed);
let checklist = fixed_checklist_rect(rect, INPUT_BASE_HEIGHT, checklist_h);
let list = fixed_checklist_list_rect(checklist);
let chevron =
checklist_item_chevron_rect(list.origin.x + PAD, list.origin.y, list.size.x - PAD * 2.0);
let point = Point2D::new(
chevron.origin.x + chevron.size.x / 2.0,
chevron.origin.y + chevron.size.y / 2.0,
);
let panel = AIChatPlaceholder::from_editor(&s);
let hit = panel.hit_test(rect, point);
assert_eq!(hit, Some(AIChatHit::ToggleChecklistItem(0)));
}

View file

@ -3,11 +3,6 @@
//! Pure geometry; the painting half stays in `ai_chat_panel.rs`.
use super::ai_chat_panel::{AIChatPlaceholder, HEADER_HEIGHT, PAD, RESIZE_CORNER, RESIZE_GUTTER};
use crate::widgets::ai_chat_checklist::{
checklist_item_chevron_rect, checklist_item_height, fixed_checklist_height,
fixed_checklist_items, fixed_checklist_list_rect, fixed_checklist_max_scroll,
fixed_checklist_rect, HEADER_H, ITEM_GAP, PROGRESS_H,
};
use crate::widgets::ai_chat_hit::{AIChatHit, ChatCursorProbe, ChatResizeEdge};
use crate::widgets::ai_chat_panel_controls::attachment_row_hit;
use crate::widgets::ai_chat_panel_header::{
@ -18,16 +13,6 @@ use crate::widgets::ai_chat_transcript_cache::CanonicalTranscript;
use crate::{Point2D, Rect};
impl<'a> AIChatPlaceholder<'a> {
pub fn fixed_checklist_bounds(&self, rect: Rect) -> Option<Rect> {
let checklist_h = fixed_checklist_height(self.state, self.state.checklist_collapsed);
(checklist_h > 0.0)
.then(|| fixed_checklist_rect(rect, self.input_height_for_rect(rect), checklist_h))
}
pub fn fixed_checklist_scroll_max(&self) -> f32 {
fixed_checklist_max_scroll(self.state, self.state.checklist_collapsed)
}
pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option<AIChatHit> {
self.hit_test_with_canonical(rect, point, None)
}
@ -273,45 +258,6 @@ impl<'a> AIChatPlaceholder<'a> {
AIChatHit::FocusInput
});
}
let checklist_h = fixed_checklist_height(self.state, self.state.checklist_collapsed);
if checklist_h > 0.0 {
let input_h = self.input_height_for_rect(rect);
let checklist = fixed_checklist_rect(rect, input_h, checklist_h);
if (checklist).contains(point) {
let header = Rect::xywh(
checklist.origin.x,
checklist.origin.y + PROGRESS_H,
checklist.size.x,
HEADER_H,
);
if (header).contains(point) {
return Some(AIChatHit::ToggleChecklist);
}
if !self.state.checklist_collapsed {
let list = fixed_checklist_list_rect(checklist);
let items = fixed_checklist_items(self.state);
let scroll = self
.fixed_checklist_scroll_max()
.min(self.state.checklist_scroll.offset.max(0.0));
let mut row_y = list.origin.y - scroll;
for (idx, item) in items.iter().enumerate() {
let h = checklist_item_height(item);
if !item.details.is_empty() {
let chevron = checklist_item_chevron_rect(
list.origin.x + PAD,
row_y,
list.size.x - PAD * 2.0,
);
if (chevron).contains(point) {
return Some(AIChatHit::ToggleChecklistItem(idx));
}
}
row_y += h + ITEM_GAP;
}
}
return Some(AIChatHit::FocusInput);
}
}
// Transcript hit-test — a click on a message's thinking /
// tool-call collapsible header toggles it. Checked before the
// drag-handle fallback so the headers are interactive.

View file

@ -186,7 +186,6 @@ mod agent_settings_switch;
#[cfg(test)]
mod agent_settings_switch_style_tests;
pub mod agent_settings_system;
mod ai_chat_checklist;
mod ai_chat_hit;
pub(crate) mod ai_chat_input_text;
pub mod ai_chat_model_picker;

View file

@ -362,16 +362,6 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
AIChatHit::ToggleChecklist => {
self.editor_state.chat.toggle_checklist_collapsed();
self.mark_dirty();
return true;
}
AIChatHit::ToggleChecklistItem(idx) => {
self.editor_state.chat.set_checklist_item_expanded(idx);
self.mark_dirty();
return true;
}
AIChatHit::SwitchTab(idx) => {
// Pure editor-state change — a switch does NOT touch
// the run binding (a run keeps streaming into its own

View file

@ -9,35 +9,6 @@ use op_editor_ui::widgets::GitPanel;
use op_editor_ui::Point2D;
impl WidgetHostNative {
fn try_scroll_chat_checklist(
&mut self,
x: f32,
y: f32,
delta: f32,
viewport_width: f32,
viewport_height: f32,
) -> bool {
use op_editor_ui::widgets::AIChatPlaceholder;
let point = Point2D::new(x, y);
let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) else {
return false;
};
let (checklist, max) = {
let panel = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms);
let Some(checklist) = panel.fixed_checklist_bounds(chat_rect) else {
return false;
};
(checklist, panel.fixed_checklist_scroll_max())
};
if !(checklist).contains(point) {
return false;
}
if scroll_by_max(&mut self.editor_state.chat.checklist_scroll, -delta, max) {
self.mark_dirty();
}
true
}
/// Scroll the chat transcript message list when a wheel / trackpad
/// pan lands over the panel body. The body swallows the event so a
/// wheel over a long reply never zooms the canvas beneath. Mirrors
@ -516,9 +487,6 @@ impl WidgetHostNative {
}
}
}
if self.try_scroll_chat_checklist(x, y, delta_y, viewport_width, viewport_height) {
return true;
}
// Chat transcript message list — a wheel over the body scrolls
// the conversation; the pinned-to-bottom auto-follow resumes once
// the user scrolls back to the bottom.
@ -691,9 +659,6 @@ impl WidgetHostNative {
}
}
}
if self.try_scroll_chat_checklist(x, y, dy, viewport_width, viewport_height) {
return true;
}
if self.try_scroll_chat_transcript(x, y, dy, viewport_width, viewport_height) {
return true;
}

View file

@ -64,34 +64,6 @@ fn layer_panel_trackpad_pan_scrolls_horizontally() {
});
}
#[test]
fn chat_checklist_wheel_scrolls_pinned_task_list() {
let mut host = WidgetHostNative::new();
let viewport_w = 1200.0;
let viewport_h = 800.0;
let mut message = op_editor_core::ChatMessage::assistant_streaming();
message.content = (0..11)
.map(|idx| format!(r#"<step title="Task {idx}" status="done"></step>"#))
.collect::<Vec<_>>()
.join("\n");
host.editor_state_mut().chat.messages.push(message);
let chat_rect = host.ai_chat_rect(viewport_w, viewport_h).unwrap();
let panel = op_editor_ui::widgets::AIChatPlaceholder::from_editor(host.editor_state());
let checklist = panel.fixed_checklist_bounds(chat_rect).unwrap();
assert!(panel.fixed_checklist_scroll_max() > 0.0);
assert!(host.apply_wheel(
checklist.origin.x + checklist.size.x / 2.0,
checklist.origin.y + checklist.size.y - 16.0,
-80.0,
viewport_w,
viewport_h
));
assert!(host.editor_state().chat.checklist_scroll.offset > 0.0);
}
#[test]
fn design_md_panel_wheel_scrolls_content_without_zooming_canvas() {
let mut host = WidgetHostNative::new();

View file

@ -773,42 +773,6 @@ impl WidgetHost {
true
}
fn try_scroll_chat_checklist(
&mut self,
x: f32,
y: f32,
delta: f32,
viewport_width: f32,
viewport_height: f32,
) -> bool {
let point = Point2D::new(x, y);
let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) else {
return false;
};
let (checklist, max) = {
let panel = op_editor_ui::widgets::AIChatPlaceholder::from_editor_at(
&self.editor_state,
self.now_ms,
);
let Some(checklist) = panel.fixed_checklist_bounds(chat_rect) else {
return false;
};
(checklist, panel.fixed_checklist_scroll_max())
};
if !(checklist).contains(point) {
return false;
}
let before = self.editor_state.chat.checklist_scroll.offset;
self.editor_state
.chat
.checklist_scroll
.scroll_by(-delta, max, 0.0);
if self.editor_state.chat.checklist_scroll.offset != before {
self.mark_dirty();
}
true
}
/// Scroll the chat transcript message list when a wheel / trackpad
/// pan lands over the panel body — pinned-to-bottom auto-follow
/// resumes once the user scrolls back to the bottom. Mirrors the
@ -910,9 +874,6 @@ impl WidgetHost {
return true;
}
}
if self.try_scroll_chat_checklist(x, y, delta_y, viewport_width, viewport_height) {
return true;
}
if self.try_scroll_chat_transcript(x, y, delta_y, viewport_width, viewport_height) {
return true;
}
@ -980,9 +941,6 @@ impl WidgetHost {
if self.try_scroll_icon_picker(x, y, dy, viewport_width, viewport_height) {
return true;
}
if self.try_scroll_chat_checklist(x, y, dy, viewport_width, viewport_height) {
return true;
}
if self.try_scroll_chat_transcript(x, y, dy, viewport_width, viewport_height) {
return true;
}

View file

@ -246,16 +246,6 @@ impl WidgetHost {
self.mark_dirty();
return true;
}
AIChatHit::ToggleChecklist => {
self.editor_state.chat.toggle_checklist_collapsed();
self.mark_dirty();
return true;
}
AIChatHit::ToggleChecklistItem(idx) => {
self.editor_state.chat.set_checklist_item_expanded(idx);
self.mark_dirty();
return true;
}
AIChatHit::SwitchTab(idx) => {
// Pure editor-state change — a switch does NOT abort an
// in-flight run; the run keeps streaming into its own