diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index f237abbdd..67c76f92a 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -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, /// 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) { + 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(); 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 56611cdbf..f8644d305 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -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 for AIChatHit { block_index, expanded, ) => Self::SetDesignBlockExpanded(message_index, block_index, expanded), + super::ai_chat_transcript::TranscriptHit::CopyDesignBlock(text) => { + Self::CopyDesignBlock(text) + } } } } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index 62708252c..631e5c759 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -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)>, diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs index 3096e5826..1ee1f2544 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs @@ -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; diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript_copy_tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript_copy_tests.rs new file mode 100644 index 000000000..f65e23591 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript_copy_tests.rs @@ -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) +} diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript_design.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript_design.rs index f29400193..78e6366d6 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_transcript_design.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript_design.rs @@ -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, } @@ -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( diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index be23c2435..1a8633223 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -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( diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index 0bcc4f8ec..ecd83e723 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -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(); diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 0c145ae52..d59e80ce7 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -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();