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:
parent
2586f06cc3
commit
075b1f25eb
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue