fix(ai): reveal design json copy on hover

This commit is contained in:
Fini 2026-06-01 00:46:41 +08:00
parent 1be8b4ac80
commit 0113cccbcf
12 changed files with 296 additions and 89 deletions

View file

@ -698,6 +698,9 @@ pub struct EditorUiState {
/// Index into `chat.available_models` of the model row the cursor
/// is over, or `None`. Drives the picker's hover-row tint.
pub chat_model_picker_hover: Option<usize>,
/// Hovered chat design JSON card `(message_index, block_index)`;
/// drives the TS-style hover reveal of the card's copy affordance.
pub chat_design_block_hover: Option<(usize, usize)>,
/// Index into `AgentProvider::ALL` of the agent driving the chat.
pub chat_selected_agent: usize,
@ -894,6 +897,7 @@ impl Default for EditorUiState {
chat_model_picker_caret: None,
chat_model_picker_caret_anchor_ms: 0,
chat_model_picker_hover: None,
chat_design_block_hover: None,
chat_selected_agent: 0,
topbar_traffic_hover: false,
window_fullscreen: false,

View file

@ -90,6 +90,7 @@ pub struct AIChatPlaceholder<'a> {
pub model_picker_caret: Option<usize>,
/// Last focus / edit timestamp for the model-picker search caret.
pub model_picker_caret_anchor_ms: u64,
pub design_hover: Option<(usize, usize)>,
/// Localised empty-state example cards.
pub(crate) examples: [ExampleCard; 4],
/// Active UI locale.
@ -122,6 +123,7 @@ impl<'a> AIChatPlaceholder<'a> {
model_picker_search: ui.chat_model_picker_search.clone(),
model_picker_caret: ui.chat_model_picker_caret,
model_picker_caret_anchor_ms: ui.chat_model_picker_caret_anchor_ms,
design_hover: ui.chat_design_block_hover,
examples: example_cards(ui.locale),
locale: ui.locale,
}
@ -150,7 +152,6 @@ impl<'a> AIChatPlaceholder<'a> {
self.state.messages.iter().any(|message| message.streaming)
}
/// Bounds of the transcript body region.
fn body_rect(&self, rect: Rect) -> Rect {
let body_top = rect.origin.y + HEADER_HEIGHT;
let body_bottom = rect.origin.y + rect.size.y
@ -164,7 +165,6 @@ impl<'a> AIChatPlaceholder<'a> {
}
}
/// Bounds of the model-picker dropdown.
fn model_picker_rect(&self, rect: Rect, input_rect: Rect) -> Rect {
let height = crate::widgets::ai_chat_model_picker::picker_view_height(
&self.state.available_models,
@ -178,7 +178,6 @@ impl<'a> AIChatPlaceholder<'a> {
}
}
/// Public bounds of the open model-picker dropdown.
pub fn model_picker_bounds(&self, rect: Rect) -> Option<Rect> {
if !self.model_picker_open {
return None;
@ -362,6 +361,19 @@ impl<'a> AIChatPlaceholder<'a> {
}
Some(AIChatHit::DragHandle)
}
pub fn design_block_hover_at(&self, rect: Rect, point: Point2D) -> Option<(usize, usize)> {
if self.state.messages.is_empty() {
return None;
}
crate::widgets::ai_chat_transcript_hit::design_block_at(
&self.state.messages,
self.body_rect(rect),
point.x,
point.y,
self.locale,
)
}
}
fn rect_contains(r: Rect, p: Point2D) -> bool {
@ -489,13 +501,14 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
!can_use_model || self.is_streaming(),
);
} else {
crate::widgets::ai_chat_transcript::paint_transcript(
crate::widgets::ai_chat_transcript::paint_transcript_with_design_hover(
cx,
&self.theme,
self.body_rect(rect),
&self.state.messages,
self.now_ms,
self.locale,
self.design_hover,
);
}
if checklist_h > 0.0 {

View file

@ -21,6 +21,7 @@ use op_editor_core::chat::{ChatMessage, ChatRole};
use super::ai_chat_transcript_design::{
extract_design_json_blocks, paint_design_block, place_design_blocks, DesignBlock,
};
pub(crate) use super::ai_chat_transcript_hit::{transcript_hit, TranscriptHit};
use super::ai_chat_transcript_steps::{
extract_step_blocks, split_design_progress, strip_tool_call_xml, ParsedStep, ParsedStepStatus,
};
@ -75,23 +76,6 @@ const CHAR_UNIT_PX: f32 = 6.6;
/// Bubble width as a fraction of the transcript body width.
const BUBBLE_FRAC: f32 = 0.84;
/// What a click inside the transcript resolved to. Both variants
/// carry the index into the full `messages` slice.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TranscriptHit {
/// The thinking-block header — toggle its collapsed state.
ToggleThinking(usize),
/// The tool-calls panel header — toggle its collapsed state.
ToggleToolCalls(usize),
/// One tool-call card header — set just that card's expanded
/// state. Carries `(message_index, tool_call_index, expanded)`.
SetToolCallCardExpanded(usize, usize, bool),
/// One design JSON card header — set just that card's expanded
/// state. Carries `(message_index, design_block_index, expanded)`.
SetDesignBlockExpanded(usize, usize, bool),
CopyDesignBlock(String),
}
/// A collapsible block (thinking text or tool-call list) — a
/// clickable `header` row plus an optional `body` box. When
/// `collapsed`, `body` has zero height and `lines` is empty.
@ -138,10 +122,6 @@ pub(crate) struct TranscriptItem {
pub streaming: bool,
}
fn rect_contains(r: Rect, x: f32, y: f32) -> bool {
x >= r.origin.x && x <= r.origin.x + r.size.x && y >= r.origin.y && y <= r.origin.y + r.size.y
}
/// Wrap-unit budget for an inner text width.
fn unit_budget(inner_w: f32) -> u32 {
(inner_w / CHAR_UNIT_PX).floor().max(1.0) as u32
@ -203,6 +183,7 @@ fn build_item(
top: f32,
body: Rect,
locale: op_editor_core::Locale,
design_hover: Option<(usize, usize)>,
) -> (TranscriptItem, f32) {
let is_user = msg.role == ChatRole::User;
let bubble_w = if is_user {
@ -318,6 +299,9 @@ fn build_item(
bubble_w,
SUB_GAP,
&msg.design_block_expanded_overrides,
design_hover.and_then(|(message_index, block_index)| {
(message_index == msg_index).then_some(block_index)
}),
);
y = next_y;
@ -426,6 +410,15 @@ pub(crate) fn build_transcript(
messages: &[ChatMessage],
body_rect: Rect,
locale: op_editor_core::Locale,
) -> Vec<TranscriptItem> {
build_transcript_with_design_hover(messages, body_rect, locale, None)
}
pub(crate) fn build_transcript_with_design_hover(
messages: &[ChatMessage],
body_rect: Rect,
locale: op_editor_core::Locale,
design_hover: Option<(usize, usize)>,
) -> Vec<TranscriptItem> {
if messages.is_empty() {
return Vec::new();
@ -435,7 +428,7 @@ pub(crate) fn build_transcript(
let mut start = messages.len();
let mut used = 0.0f32;
for i in (0..messages.len()).rev() {
let (_, bottom) = build_item(&messages[i], i, 0.0, body_rect, locale);
let (_, bottom) = build_item(&messages[i], i, 0.0, body_rect, locale, None);
let h = bottom
+ if start == messages.len() {
0.0
@ -452,64 +445,13 @@ pub(crate) fn build_transcript(
let mut items = Vec::new();
let mut top = body_rect.origin.y;
for (i, msg) in messages.iter().enumerate().skip(start) {
let (item, bottom) = build_item(msg, i, top, body_rect, locale);
let (item, bottom) = build_item(msg, i, top, body_rect, locale, design_hover);
items.push(item);
top = bottom + MSG_GAP;
}
items
}
/// Resolve a click inside the transcript body to a [`TranscriptHit`]
/// — only the collapsible headers are interactive.
pub(crate) fn transcript_hit(
messages: &[ChatMessage],
body_rect: Rect,
x: f32,
y: f32,
locale: op_editor_core::Locale,
) -> Option<TranscriptHit> {
// Paint clips the transcript to `body_rect`; gate hit-test the
// same way so a click in the body/input gap can't toggle a
// header that an over-tall latest message placed off-screen.
if !rect_contains(body_rect, x, y) {
return None;
}
for item in build_transcript(messages, body_rect, locale) {
if let Some(t) = &item.thinking {
if rect_contains(t.header, x, y) {
return Some(TranscriptHit::ToggleThinking(item.msg_index));
}
}
if let Some(t) = &item.tools {
if rect_contains(t.header, x, y) {
return Some(TranscriptHit::ToggleToolCalls(item.msg_index));
}
for (tool_index, card) in t.cards.iter().enumerate() {
if rect_contains(card.header, x, y) {
return Some(TranscriptHit::SetToolCallCardExpanded(
item.msg_index,
tool_index,
!card.expanded,
));
}
}
}
for (block_index, block) in item.design_blocks.iter().enumerate() {
if rect_contains(block.copy, x, y) {
return Some(TranscriptHit::CopyDesignBlock(block.code.clone()));
}
if rect_contains(block.header, x, y) {
return Some(TranscriptHit::SetDesignBlockExpanded(
item.msg_index,
block_index,
!block.expanded,
));
}
}
}
None
}
/// Draw one wrapped text line. Small shared helper so the bubble,
/// thinking body and tool list paint identically.
fn draw_line(
@ -685,6 +627,7 @@ fn paint_typing_dots(
/// Paint the chat transcript — the tail of `messages` that fits in
/// `body_rect`, with collapsible thinking / tool blocks, image
/// thumbnails and the streaming animation on the in-flight message.
#[cfg(test)]
pub(crate) fn paint_transcript(
cx: &mut PaintCx<'_>,
theme: &Theme,
@ -692,10 +635,22 @@ pub(crate) fn paint_transcript(
messages: &[ChatMessage],
now_ms: u64,
locale: op_editor_core::Locale,
) {
paint_transcript_with_design_hover(cx, theme, body_rect, messages, now_ms, locale, None);
}
pub(crate) fn paint_transcript_with_design_hover(
cx: &mut PaintCx<'_>,
theme: &Theme,
body_rect: Rect,
messages: &[ChatMessage],
now_ms: u64,
locale: op_editor_core::Locale,
design_hover: Option<(usize, usize)>,
) {
cx.backend.save();
cx.backend.clip_rect(body_rect);
for item in build_transcript(messages, body_rect, locale) {
for item in build_transcript_with_design_hover(messages, body_rect, locale, design_hover) {
for step in &item.steps {
paint_action_step(cx, theme, step);
}

View file

@ -23,3 +23,84 @@ fn transcript_hit_resolves_design_json_copy_button_like_ts() {
fn body() -> Rect {
Rect::xywh(0.0, 0.0, 340.0, 300.0)
}
#[test]
fn paint_design_json_copy_icon_is_hidden_until_card_hover_like_ts() {
let message = ChatMessage::assistant(
r#"```json
[{"id":"frame-1","type":"Frame"}]
```"#,
);
let messages = [message];
let mut backend = CopyPaintBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
paint_transcript(
&mut cx,
&crate::Theme::dark(),
body(),
&messages,
0,
op_editor_core::Locale::EnUs,
);
assert_eq!(backend.copy_icon_strokes, 0);
}
#[test]
fn paint_design_json_copy_icon_is_visible_for_hovered_card_like_ts() {
let message = ChatMessage::assistant(
r#"```json
[{"id":"frame-1","type":"Frame"}]
```"#,
);
let messages = [message];
let mut backend = CopyPaintBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
paint_transcript_with_design_hover(
&mut cx,
&crate::Theme::dark(),
body(),
&messages,
0,
op_editor_core::Locale::EnUs,
Some((0, 0)),
);
assert!(backend.copy_icon_strokes > 0);
}
#[derive(Default)]
struct CopyPaintBackend {
copy_icon_strokes: usize,
}
impl crate::RenderBackend for CopyPaintBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _: Rect, _: crate::Color) {}
fn stroke_rect(&mut self, _: Rect, _: crate::Color, _: f32) {}
fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) {}
fn clip_rect(&mut self, _: Rect) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: crate::Color, _: f32) {}
fn fill_round_rect(&mut self, _: Rect, _: f32, _: crate::Color) {}
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {}
fn stroke_svg_path(&mut self, _: &str, point: Point2D, _: f32, _: crate::Color, _: f32) {
if (290.0..=305.0).contains(&point.x) {
self.copy_icon_strokes += 1;
}
}
fn fill_oval(&mut self, _: Rect, _: crate::Color) {}
fn resize(&mut self, _: u32, _: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}

View file

@ -25,6 +25,7 @@ pub(crate) struct DesignBlock {
pub label: String,
pub streaming: bool,
pub code: String,
pub copy_visible: bool,
pub code_lines: Vec<String>,
}
@ -146,6 +147,7 @@ pub(crate) fn place_design_blocks(
width: f32,
gap: f32,
expanded_overrides: &[Option<bool>],
hovered_index: Option<usize>,
) -> (Vec<DesignBlock>, f32) {
const BODY_PAD_Y: f32 = 8.0;
const BODY_LINE_H: f32 = 13.0;
@ -189,6 +191,7 @@ pub(crate) fn place_design_blocks(
label: pending.label,
streaming: pending.streaming,
code: pending.code,
copy_visible: hovered_index == Some(index),
code_lines,
});
y += DESIGN_BLOCK_H + body_h + gap;
@ -252,16 +255,18 @@ pub(crate) fn paint_design_block(cx: &mut PaintCx<'_>, theme: &Theme, block: &De
);
cx.backend.restore();
let mut copy_color = theme.muted_foreground;
copy_color.a *= 0.5;
draw_icon(
cx.backend,
Icon::Copy,
Point2D::new(block.copy.origin.x + 4.0, block.copy.origin.y + 4.0),
12.0,
copy_color,
1.5,
);
if block.copy_visible {
let mut copy_color = theme.muted_foreground;
copy_color.a *= 0.5;
draw_icon(
cx.backend,
Icon::Copy,
Point2D::new(block.copy.origin.x + 4.0, block.copy.origin.y + 4.0),
12.0,
copy_color,
1.5,
);
}
let mut chevron_color = theme.muted_foreground;
chevron_color.a *= 0.45;

View file

@ -0,0 +1,82 @@
use super::ai_chat_transcript::build_transcript;
use crate::Rect;
use op_editor_core::chat::ChatMessage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TranscriptHit {
ToggleThinking(usize),
ToggleToolCalls(usize),
SetToolCallCardExpanded(usize, usize, bool),
SetDesignBlockExpanded(usize, usize, bool),
CopyDesignBlock(String),
}
fn rect_contains(r: Rect, x: f32, y: f32) -> bool {
x >= r.origin.x && x <= r.origin.x + r.size.x && y >= r.origin.y && y <= r.origin.y + r.size.y
}
pub(crate) fn transcript_hit(
messages: &[ChatMessage],
body_rect: Rect,
x: f32,
y: f32,
locale: op_editor_core::Locale,
) -> Option<TranscriptHit> {
if !rect_contains(body_rect, x, y) {
return None;
}
for item in build_transcript(messages, body_rect, locale) {
if let Some(t) = &item.thinking {
if rect_contains(t.header, x, y) {
return Some(TranscriptHit::ToggleThinking(item.msg_index));
}
}
if let Some(t) = &item.tools {
if rect_contains(t.header, x, y) {
return Some(TranscriptHit::ToggleToolCalls(item.msg_index));
}
for (tool_index, card) in t.cards.iter().enumerate() {
if rect_contains(card.header, x, y) {
return Some(TranscriptHit::SetToolCallCardExpanded(
item.msg_index,
tool_index,
!card.expanded,
));
}
}
}
for (block_index, block) in item.design_blocks.iter().enumerate() {
if rect_contains(block.copy, x, y) {
return Some(TranscriptHit::CopyDesignBlock(block.code.clone()));
}
if rect_contains(block.header, x, y) {
return Some(TranscriptHit::SetDesignBlockExpanded(
item.msg_index,
block_index,
!block.expanded,
));
}
}
}
None
}
pub(crate) fn design_block_at(
messages: &[ChatMessage],
body_rect: Rect,
x: f32,
y: f32,
locale: op_editor_core::Locale,
) -> Option<(usize, usize)> {
if !rect_contains(body_rect, x, y) {
return None;
}
for item in build_transcript(messages, body_rect, locale) {
for (block_index, block) in item.design_blocks.iter().enumerate() {
if rect_contains(block.rect, x, y) {
return Some((item.msg_index, block_index));
}
}
}
None
}

View file

@ -126,6 +126,7 @@ pub mod ai_chat_panel_controls;
pub mod ai_chat_panel_paint;
pub mod ai_chat_transcript;
pub(crate) mod ai_chat_transcript_design;
pub(crate) mod ai_chat_transcript_hit;
pub(crate) mod ai_chat_transcript_steps;
pub(crate) mod ai_chat_transcript_text;
pub(crate) mod ai_chat_transcript_tools;

View file

@ -40,6 +40,9 @@ mod agent_settings_draft_dispatch;
#[cfg(test)]
mod agent_settings_tests;
mod ai_chat_geometry;
mod chat_design_hover;
#[cfg(test)]
mod chat_design_hover_tests;
mod chat_model_picker_caret;
#[cfg(test)]
mod chat_model_picker_caret_tests;

View file

@ -0,0 +1,28 @@
use super::WidgetHostNative;
use op_editor_ui::widgets::AIChatPlaceholder;
use op_editor_ui::Point2D;
impl WidgetHostNative {
pub(in crate::widget_host) fn update_chat_design_hover(
&mut self,
x: f32,
y: f32,
over_topmost: bool,
) -> bool {
let new_hover = if !over_topmost {
self.ai_chat_rect(self.last_viewport_w, self.last_viewport_h)
.and_then(|chat_rect| {
let panel = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms);
panel.design_block_hover_at(chat_rect, Point2D::new(x, y))
})
} else {
None
};
if new_hover == self.editor_state.editor_ui.chat_design_block_hover {
return false;
}
self.editor_state.editor_ui.chat_design_block_hover = new_hover;
self.mark_dirty();
true
}
}

View file

@ -0,0 +1,31 @@
use super::*;
use op_editor_ui::widgets::AIChatPlaceholder;
use op_editor_ui::Point2D;
#[test]
fn cursor_move_tracks_hovered_design_json_card_for_copy_reveal() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant(
r#"```json
[{"id":"frame-1","type":"Frame"}]
```"#,
));
let viewport_w = 1440.0;
let viewport_h = 900.0;
host.last_viewport_w = viewport_w;
host.last_viewport_h = viewport_h;
let chat_rect = host.ai_chat_rect(viewport_w, viewport_h).unwrap();
let panel = AIChatPlaceholder::from_editor(host.editor_state());
let point = Point2D::new(chat_rect.origin.x + 24.0, chat_rect.origin.y + 52.0);
assert_eq!(panel.design_block_hover_at(chat_rect, point), Some((0, 0)));
assert!(host.apply_cursor_move(point.x, point.y));
assert_eq!(
host.editor_state().editor_ui.chat_design_block_hover,
Some((0, 0))
);
}

View file

@ -73,6 +73,7 @@ impl WidgetHostNative {
changed |= ui.toolbar_hover.take().is_some();
changed |= ui.align_toolbar_hover.take().is_some();
changed |= ui.chat_model_picker_hover.take().is_some();
changed |= ui.chat_design_block_hover.take().is_some();
changed |= ui.export_picker_hover.take().is_some();
if let Some(menu) = ui.layer_context_menu.as_mut() {
changed |= menu.hovered_row.take().is_some();

View file

@ -314,6 +314,9 @@ impl WidgetHostNative {
}
}
}
if self.update_chat_design_hover(x, y, over_topmost) {
return true;
}
if let Some(drag) = self.rotate_drag {
let cursor_angle = (y - drag.center_screen_y).atan2(x - drag.center_screen_x);
let new_rotation = drag.start_rotation + (cursor_angle - drag.start_cursor_angle);