fix(ai): copy design json cards

This commit is contained in:
Fini 2026-06-01 00:19:29 +08:00
parent 5161da5008
commit 1be8b4ac80
9 changed files with 118 additions and 2 deletions

View file

@ -318,6 +318,9 @@ pub struct ChatState {
/// affordance. Unlike New Chat, the transcript stays visible; the
/// desktop event loop only drops the in-flight worker.
pub pending_stop_chat: bool,
/// Raised when the user clicks a transcript copy affordance; hosts
/// drain this into the platform clipboard.
pub pending_copy_text: Option<String>,
/// Full model catalog discovered from every *installed* CLI,
/// before the connected-providers filter. The desktop host fills
/// this from `model_discovery`; [`rebuild_available_models`] then
@ -374,6 +377,7 @@ impl Default for ChatState {
pending_send: None,
pending_new_chat: false,
pending_stop_chat: false,
pending_copy_text: None,
discovered_models: Vec::new(),
available_models: Vec::new(),
selected_model: 0,
@ -512,11 +516,16 @@ impl ChatState {
self.input.clear();
self.pending_send = None;
self.pending_stop_chat = false;
self.pending_copy_text = None;
self.pending_attachments.clear();
self.pending_attachment_pick = false;
self.pending_new_chat = true;
}
pub fn queue_copy_text(&mut self, text: impl Into<String>) {
self.pending_copy_text = Some(text.into());
}
/// Flip the collapsed state of message `idx`'s thinking block.
/// Out-of-range index is a no-op.
pub fn toggle_message_thinking(&mut self, idx: usize) {
@ -961,6 +970,15 @@ mod tests {
);
}
#[test]
fn queue_copy_text_records_pending_clipboard_payload() {
let mut chat = ChatState::default();
chat.queue_copy_text("json");
assert_eq!(chat.pending_copy_text.as_deref(), Some("json"));
}
#[test]
fn toggle_checklist_collapsed_flips_panel_checklist_flag() {
let mut chat = ChatState::default();

View file

@ -59,6 +59,8 @@ pub enum AIChatHit {
/// Click on a single design JSON card header — host sets only
/// that card's expanded override.
SetDesignBlockExpanded(usize, usize, bool),
/// Click on a design JSON card's copy affordance.
CopyDesignBlock(String),
/// Click on the fixed "Pencil it out" checklist header — host
/// toggles the checklist body between expanded and collapsed.
ToggleChecklist,
@ -81,6 +83,9 @@ impl From<super::ai_chat_transcript::TranscriptHit> for AIChatHit {
block_index,
expanded,
) => Self::SetDesignBlockExpanded(message_index, block_index, expanded),
super::ai_chat_transcript::TranscriptHit::CopyDesignBlock(text) => {
Self::CopyDesignBlock(text)
}
}
}
}

View file

@ -411,6 +411,37 @@ fn hit_test_resolves_design_block_header_toggle() {
);
}
#[test]
fn hit_test_resolves_design_block_copy_button() {
let code = r#"[{"id":"frame-1","type":"Frame"}]"#;
let mut s = EditorState::new();
s.chat
.messages
.push(op_editor_core::ChatMessage::assistant(format!(
r#"```json
{code}
```"#
)));
let panel = AIChatPlaceholder::from_editor(&s);
let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT);
let block = &crate::widgets::ai_chat_transcript::build_transcript(
&s.chat.messages,
panel.body_rect(rect),
panel.locale,
)[0]
.design_blocks[0];
let p = Point2D::new(
block.header.origin.x + block.header.size.x - 38.0,
block.header.origin.y + block.header.size.y / 2.0,
);
assert_eq!(
panel.hit_test(rect, p),
Some(AIChatHit::CopyDesignBlock(code.to_string()))
);
}
#[derive(Default)]
struct PanelPaintBackend {
fills: Vec<(Rect, crate::Color)>,

View file

@ -77,7 +77,7 @@ 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, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TranscriptHit {
/// The thinking-block header — toggle its collapsed state.
ToggleThinking(usize),
@ -89,6 +89,7 @@ pub enum TranscriptHit {
/// 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
@ -494,6 +495,9 @@ pub(crate) fn transcript_hit(
}
}
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,
@ -789,3 +793,7 @@ pub(crate) fn paint_transcript(
#[cfg(test)]
#[path = "ai_chat_transcript_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "ai_chat_transcript_copy_tests.rs"]
mod copy_tests;

View file

@ -0,0 +1,25 @@
use super::*;
#[test]
fn transcript_hit_resolves_design_json_copy_button_like_ts() {
let code = r#"[{"id":"frame-1","type":"Frame"}]"#;
let message = ChatMessage::assistant(format!(
r#"```json
{code}
```"#
));
let messages = std::slice::from_ref(&message);
let block =
&build_transcript(messages, body(), op_editor_core::Locale::EnUs)[0].design_blocks[0];
let x = block.header.origin.x + block.header.size.x - 38.0;
let y = block.header.origin.y + block.header.size.y / 2.0;
assert_eq!(
transcript_hit(messages, body(), x, y, op_editor_core::Locale::EnUs),
Some(TranscriptHit::CopyDesignBlock(code.to_string()))
);
}
fn body() -> Rect {
Rect::xywh(0.0, 0.0, 340.0, 300.0)
}

View file

@ -18,11 +18,13 @@ pub(crate) struct PendingDesignBlock {
pub(crate) struct DesignBlock {
pub rect: Rect,
pub header: Rect,
pub copy: Rect,
pub body: Rect,
pub expanded: bool,
pub element_count: usize,
pub label: String,
pub streaming: bool,
pub code: String,
pub code_lines: Vec<String>,
}
@ -175,15 +177,18 @@ pub(crate) fn place_design_blocks(
};
let rect = Rect::xywh(x, y, width, DESIGN_BLOCK_H + body_h);
let header = Rect::xywh(x, y, width, DESIGN_BLOCK_H);
let copy = Rect::xywh(x + width - 48.0, y + 6.0, 20.0, 20.0);
let body = Rect::xywh(x, y + DESIGN_BLOCK_H, width, body_h);
blocks.push(DesignBlock {
rect,
header,
copy,
body,
expanded,
element_count: pending.element_count,
label: pending.label,
streaming: pending.streaming,
code: pending.code,
code_lines,
});
y += DESIGN_BLOCK_H + body_h + gap;
@ -231,7 +236,7 @@ pub(crate) fn paint_design_block(cx: &mut PaintCx<'_>, theme: &Theme, block: &De
cx.backend.clip_rect(Rect::xywh(
block.header.origin.x + 30.0,
block.header.origin.y,
(block.header.size.x - 58.0).max(1.0),
(block.header.size.x - 86.0).max(1.0),
block.header.size.y,
));
let layout = TextLayout::single_run(
@ -247,6 +252,17 @@ 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,
);
let mut chevron_color = theme.muted_foreground;
chevron_color.a *= 0.45;
draw_icon(

View file

@ -698,6 +698,9 @@ impl ApplicationHandler for DesktopApp {
self.viewport_width,
self.viewport_height,
);
if let Some(text) = self.host.editor_state_mut().chat.pending_copy_text.take() {
crate::clipboard::set_text(&text);
}
// A click on the chat Send button raises
// `pending_send` — launch the provider turn.
if chat_session::launch_if_pending(

View file

@ -274,6 +274,11 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
AIChatHit::CopyDesignBlock(text) => {
self.editor_state.chat.queue_copy_text(text);
self.mark_dirty();
return true;
}
AIChatHit::ToggleChecklist => {
self.editor_state.chat.toggle_checklist_collapsed();
self.mark_dirty();

View file

@ -735,6 +735,11 @@ impl WidgetHost {
self.mark_dirty();
return true;
}
AIChatHit::CopyDesignBlock(text) => {
self.editor_state.chat.queue_copy_text(text);
self.mark_dirty();
return true;
}
AIChatHit::ToggleChecklist => {
self.editor_state.chat.toggle_checklist_collapsed();
self.mark_dirty();