From 075b1f25eba4ecbcaf6f77c6d48f5cdc4ddec84d Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 12 Jul 2026 03:34:36 +0800 Subject: [PATCH] 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. --- crates/op-editor-core/src/chat.rs | 63 -- .../src/widgets/ai_chat_checklist.rs | 791 ------------------ .../op-editor-ui/src/widgets/ai_chat_hit.rs | 6 - .../op-editor-ui/src/widgets/ai_chat_panel.rs | 21 +- .../widgets/ai_chat_panel/tests_transcript.rs | 112 --- .../src/widgets/ai_chat_panel_hit.rs | 54 -- crates/op-editor-ui/src/widgets/mod.rs | 1 - .../op-host-native/src/widget_host/click.rs | 10 - .../op-host-native/src/widget_host/scroll.rs | 35 - .../src/widget_host/scroll_tests.rs | 28 - crates/op-host-web/src/widget_host.rs | 42 - crates/op-host-web/src/widget_host/click.rs | 10 - 12 files changed, 2 insertions(+), 1171 deletions(-) delete mode 100644 crates/op-editor-ui/src/widgets/ai_chat_checklist.rs diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index bbde6a5a2..728e0a833 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -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, - /// 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); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs b/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs deleted file mode 100644 index d2d9f8e27..000000000 --- a/crates/op-editor-ui/src/widgets/ai_chat_checklist.rs +++ /dev/null @@ -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, - 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 { - let messages = &chat.messages; - let Some(message) = messages - .iter() - .rev() - .find(|msg| msg.role == ChatRole::Assistant) - else { - return Vec::new(); - }; - - let mut steps: Vec = 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("") - || 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 = 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 { - 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 { - serde_json::from_str::(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::() - + (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, &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#" -"# - .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#" -"# - .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#" -"#, - ); - - 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#" -"# - .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#" -[done] Checked constraints -[pending] Choose layout -"# - .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#""#.into(); - let mut detailed = ChatMessage::assistant_streaming(); - detailed.content = r#" -Checked constraints -Choose layout -"# - .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#""#)) - .collect::>() - .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" - ); - } -} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs index 79d89f401..19d6d25ca 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -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), diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index ed5a037fa..4e7a62221 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -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(); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_transcript.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_transcript.rs index 72a743b8f..cabd04279 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_transcript.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_transcript.rs @@ -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#"Analyzing request..."# - .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#"Generated section {idx}"#) - }) - .collect::>() - .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#" -"# - .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#" -"# - .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))); -} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs index d3f854f00..6f0f12f1c 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs @@ -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 { - 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 { 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. diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index d25692d9d..7e23b906f 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -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; diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index 10e711da0..b98069f8c 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -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 diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index 55648b7c2..660dc84e8 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -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; } diff --git a/crates/op-host-native/src/widget_host/scroll_tests.rs b/crates/op-host-native/src/widget_host/scroll_tests.rs index b6e4ff153..cf7510773 100644 --- a/crates/op-host-native/src/widget_host/scroll_tests.rs +++ b/crates/op-host-native/src/widget_host/scroll_tests.rs @@ -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#""#)) - .collect::>() - .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(); diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index 6257c08ed..3db18feb8 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -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; } diff --git a/crates/op-host-web/src/widget_host/click.rs b/crates/op-host-web/src/widget_host/click.rs index 6bef6589c..7226980a2 100644 --- a/crates/op-host-web/src/widget_host/click.rs +++ b/crates/op-host-web/src/widget_host/click.rs @@ -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