fix(ai): copy design json cards
This commit is contained in:
parent
5161da5008
commit
1be8b4ac80
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue