diff --git a/crates/op-editor-core/src/chat.rs b/crates/op-editor-core/src/chat.rs index 67c76f92a..718eff72e 100644 --- a/crates/op-editor-core/src/chat.rs +++ b/crates/op-editor-core/src/chat.rs @@ -285,6 +285,9 @@ impl ChatAnchor { } } +pub const DEFAULT_CHAT_PANEL_WIDTH: f32 = 360.0; +pub const DEFAULT_CHAT_PANEL_HEIGHT: f32 = 400.0; + /// Floating AI chat panel state — mirrors shell-core's `ChatState` /// (messages, input draft, focused flag, panel anchor, model catalog). #[derive(Debug, Clone)] @@ -294,6 +297,16 @@ pub struct ChatState { pub focused: bool, /// Which canvas corner the floating chat panel snaps to. pub anchor: ChatAnchor, + /// Non-maximized panel width. TS persists this as + /// `panelWidth` in the AI UI store. + pub panel_width: f32, + /// Non-maximized panel height. TS persists this as + /// `panelHeight` in the AI UI store. + pub panel_height: f32, + /// Absolute top-left while the user has resized from an edge + /// that moves the panel origin. `None` falls back to the snapped + /// corner anchor. + pub panel_position: Option<(f32, f32)>, /// Collapsed state — when true the panel paints only its header. pub collapsed: bool, /// Maximized state — when true the host lays the panel out across @@ -370,6 +383,9 @@ impl Default for ChatState { input: String::new(), focused: false, anchor: ChatAnchor::BottomLeft, + panel_width: DEFAULT_CHAT_PANEL_WIDTH, + panel_height: DEFAULT_CHAT_PANEL_HEIGHT, + panel_position: None, collapsed: false, maximized: false, checklist_collapsed: false, 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 f8644d305..6033976c9 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -14,6 +14,8 @@ pub enum AIChatHit { /// Click landed on the header / margin — host should start a /// drag so the user can move the panel between canvas corners. DragHandle, + /// Press landed on one of the invisible TS-style resize handles. + Resize(ChatResizeEdge), /// Click on the chevron at the top-left of the header — host /// flips the `ChatState::collapsed` flag. ToggleCollapse, @@ -66,6 +68,18 @@ pub enum AIChatHit { ToggleChecklist, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatResizeEdge { + N, + S, + E, + W, + Ne, + Nw, + Se, + Sw, +} + impl From for AIChatHit { fn from(hit: super::ai_chat_transcript::TranscriptHit) -> Self { match hit { diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index d83e9c0ff..19deb361d 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -2,7 +2,7 @@ use crate::theme::Theme; use crate::widgets::ai_chat_checklist::{ fixed_checklist_height, fixed_checklist_rect, paint_fixed_checklist, HEADER_H, PROGRESS_H, }; -use crate::widgets::ai_chat_hit::AIChatHit; +use crate::widgets::ai_chat_hit::{AIChatHit, ChatResizeEdge}; use crate::widgets::ai_chat_panel_controls::{ attachment_row_hit, paint_attachment_row, ATTACHMENT_ROW_HEIGHT, }; @@ -16,12 +16,17 @@ use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::chat::ChatState; use op_editor_core::EditorState; -pub const AI_CHAT_WIDTH: f32 = 360.0; -pub const AI_CHAT_HEIGHT: f32 = 400.0; +pub const AI_CHAT_WIDTH: f32 = op_editor_core::chat::DEFAULT_CHAT_PANEL_WIDTH; +pub const AI_CHAT_HEIGHT: f32 = op_editor_core::chat::DEFAULT_CHAT_PANEL_HEIGHT; +pub const AI_CHAT_MIN_WIDTH: f32 = 280.0; +pub const AI_CHAT_MIN_HEIGHT: f32 = 250.0; +pub const AI_CHAT_MAX_RATIO: f32 = 0.8; pub const AI_CHAT_COLLAPSED_WIDTH: f32 = 150.0; pub const AI_CHAT_COLLAPSED_HEIGHT: f32 = 36.0; pub(crate) const PAD: f32 = 16.0; pub(crate) const HEADER_HEIGHT: f32 = 36.0; +const RESIZE_GUTTER: f32 = 4.0; +const RESIZE_CORNER: f32 = 12.0; const INPUT_AREA_HEIGHT: f32 = 56.0; const INPUT_TOOLBAR_HEIGHT: f32 = 40.0; const MODEL_CHIP_W: f32 = 150.0; @@ -194,6 +199,9 @@ impl<'a> AIChatPlaceholder<'a> { } pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option { + if let Some(edge) = self.resize_edge_at(rect, point) { + return Some(AIChatHit::Resize(edge)); + } if !rect_contains(rect, point) { return None; } @@ -362,6 +370,58 @@ impl<'a> AIChatPlaceholder<'a> { Some(AIChatHit::DragHandle) } + pub fn resize_edge_at(&self, rect: Rect, point: Point2D) -> Option { + if self.state.collapsed || self.state.maximized { + return None; + } + let left = rect.origin.x; + let right = rect.origin.x + rect.size.x; + let top = rect.origin.y; + let bottom = rect.origin.y + rect.size.y; + let outer = Rect::xywh( + left - RESIZE_GUTTER, + top - RESIZE_GUTTER, + rect.size.x + RESIZE_GUTTER * 2.0, + rect.size.y + RESIZE_GUTTER * 2.0, + ); + if !rect_contains(outer, point) { + return None; + } + + let near_top = (point.y - top).abs() <= RESIZE_GUTTER; + let near_bottom = (point.y - bottom).abs() <= RESIZE_GUTTER; + let near_left = (point.x - left).abs() <= RESIZE_GUTTER; + let near_right = (point.x - right).abs() <= RESIZE_GUTTER; + let in_left_corner = point.x <= left + RESIZE_CORNER; + let in_right_corner = point.x >= right - RESIZE_CORNER; + let in_top_corner = point.y <= top + RESIZE_CORNER; + let in_bottom_corner = point.y >= bottom - RESIZE_CORNER; + + match ( + near_top && in_left_corner, + near_top && in_right_corner, + near_bottom && in_left_corner, + near_bottom && in_right_corner, + ) { + (true, _, _, _) => return Some(ChatResizeEdge::Nw), + (_, true, _, _) => return Some(ChatResizeEdge::Ne), + (_, _, true, _) => return Some(ChatResizeEdge::Sw), + (_, _, _, true) => return Some(ChatResizeEdge::Se), + _ => {} + } + if near_top { + Some(ChatResizeEdge::N) + } else if near_bottom { + Some(ChatResizeEdge::S) + } else if near_left && !in_top_corner && !in_bottom_corner { + Some(ChatResizeEdge::W) + } else if near_right && !in_top_corner && !in_bottom_corner { + Some(ChatResizeEdge::E) + } else { + None + } + } + pub fn design_block_hover_at(&self, rect: Rect, point: Point2D) -> Option<(usize, usize)> { if self.state.messages.is_empty() { return None; @@ -392,7 +452,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { LayoutBox { rect: Rect { origin: Point2D::new(0.0, 0.0), - size: Point2D::new(AI_CHAT_WIDTH, AI_CHAT_HEIGHT), + size: Point2D::new(self.state.panel_width, self.state.panel_height), }, } } 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 631e5c759..e219eac50 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 @@ -249,6 +249,42 @@ fn hit_test_header_returns_drag_handle() { assert_eq!(panel.hit_test(rect, p), Some(AIChatHit::DragHandle)); } +#[test] +fn resize_edge_at_resolves_all_ts_handles_when_not_maximized() { + let s = EditorState::new(); + let panel = AIChatPlaceholder::from_editor(&s); + let rect = Rect::xywh(100.0, 80.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); + let mid_x = rect.origin.x + rect.size.x / 2.0; + let mid_y = rect.origin.y + rect.size.y / 2.0; + let right = rect.origin.x + rect.size.x; + let bottom = rect.origin.y + rect.size.y; + + let cases = [ + (Point2D::new(mid_x, rect.origin.y + 2.0), ChatResizeEdge::N), + (Point2D::new(mid_x, bottom - 2.0), ChatResizeEdge::S), + (Point2D::new(right - 2.0, mid_y), ChatResizeEdge::E), + (Point2D::new(rect.origin.x + 2.0, mid_y), ChatResizeEdge::W), + ( + Point2D::new(right - 2.0, rect.origin.y + 2.0), + ChatResizeEdge::Ne, + ), + ( + Point2D::new(rect.origin.x + 2.0, rect.origin.y + 2.0), + ChatResizeEdge::Nw, + ), + (Point2D::new(right - 2.0, bottom - 2.0), ChatResizeEdge::Se), + ( + Point2D::new(rect.origin.x + 2.0, bottom - 2.0), + ChatResizeEdge::Sw, + ), + ]; + + for (point, edge) in cases { + assert_eq!(panel.resize_edge_at(rect, point), Some(edge)); + assert_eq!(panel.hit_test(rect, point), Some(AIChatHit::Resize(edge))); + } +} + #[test] fn hit_test_resolves_header_maximize_button() { let s = EditorState::new(); diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 6519410d3..9d49bb0a3 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -177,10 +177,10 @@ pub use canvas_viewport::{ pub use icons::{draw_icon, draw_icon_catalog_entry, draw_icon_data, Icon, IconPathData}; -pub use ai_chat_hit::AIChatHit; +pub use ai_chat_hit::{AIChatHit, ChatResizeEdge}; pub use ai_chat_panel::{ AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT, - AI_CHAT_WIDTH, + AI_CHAT_MAX_RATIO, AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH, AI_CHAT_WIDTH, }; pub use align_toolbar::{AlignToolbar, AlignToolbarHit, ALIGN_TOOLBAR_HEIGHT, ALIGN_TOOLBAR_WIDTH}; pub use component_browser_panel::{ diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index de89d758e..b55715fc6 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -153,6 +153,9 @@ pub struct WidgetHostNative { /// host computes the nearest corner via `ChatAnchor::nearest` /// and snaps. pub(in crate::widget_host) chat_drag: Option, + /// Active chat-panel resize — mirrors the TS panel's invisible + /// edge/corner handles. + pub(in crate::widget_host) chat_resize: Option, /// Active Design-MD panel drag — present while the user drags the /// floating panel by its header bar. The live top-left is written /// straight back into `editor_ui.design_md_panel_pos`. @@ -404,6 +407,14 @@ pub(in crate::widget_host) struct ChatDragState { pub(in crate::widget_host) pos_y: f32, } +#[derive(Debug, Clone, Copy)] +pub(in crate::widget_host) struct ChatResizeState { + pub(in crate::widget_host) edge: op_editor_ui::widgets::ChatResizeEdge, + pub(in crate::widget_host) start_x: f32, + pub(in crate::widget_host) start_y: f32, + pub(in crate::widget_host) start_rect: Rect, +} + #[derive(Debug, Clone, Copy)] pub(in crate::widget_host) struct DesignMdDragState { /// Pointer offset within the panel rect when the drag began — @@ -439,6 +450,7 @@ impl WidgetHostNative { theme: Theme::dark(), drag: None, chat_drag: None, + chat_resize: None, design_md_drag: None, component_browser_drag: None, icon_picker_drag: None, diff --git a/crates/op-host-native/src/widget_host/ai_chat_geometry.rs b/crates/op-host-native/src/widget_host/ai_chat_geometry.rs index 9edb206f0..77a5b3265 100644 --- a/crates/op-host-native/src/widget_host/ai_chat_geometry.rs +++ b/crates/op-host-native/src/widget_host/ai_chat_geometry.rs @@ -4,7 +4,7 @@ use super::helpers::{AICHAT_INSET_BOTTOM, AICHAT_INSET_LEFT}; use super::WidgetHostNative; use op_editor_core::ChatAnchor; use op_editor_ui::widgets::{ - AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT, AI_CHAT_WIDTH, + AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH, }; use op_editor_ui::{Point2D, Rect}; @@ -13,7 +13,10 @@ impl WidgetHostNative { if self.editor_state.chat.collapsed { (AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_COLLAPSED_HEIGHT) } else { - (AI_CHAT_WIDTH, AI_CHAT_HEIGHT) + ( + self.editor_state.chat.panel_width.max(AI_CHAT_MIN_WIDTH), + self.editor_state.chat.panel_height.max(AI_CHAT_MIN_HEIGHT), + ) } } @@ -43,6 +46,12 @@ impl WidgetHostNative { size: Point2D::new(panel_w, panel_h), }); } + if let Some((x, y)) = self.editor_state.chat.panel_position { + return Some(Rect { + origin: Point2D::new(x, y), + size: Point2D::new(panel_w, panel_h), + }); + } let (x, y) = match self.editor_state.chat.anchor { ChatAnchor::TopLeft => (cx0 + AICHAT_INSET_LEFT, cy0 + AICHAT_INSET_BOTTOM), ChatAnchor::TopRight => ( diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index ecd83e723..424260f0a 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -162,6 +162,10 @@ impl WidgetHostNative { // this; reaching here is a path bypass. return false; } + AIChatHit::Resize(_) => { + // Resize handles are press-drag only. + return false; + } AIChatHit::ToggleCollapse => { self.editor_state.chat.collapsed = !self.editor_state.chat.collapsed; self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index 4b96384f2..ab7db1437 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -10,8 +10,8 @@ use super::helpers::{rect_contains, PANEL_RESIZE_GUTTER, STATUS_INSET}; use super::{CursorHint, PanelResizeKind, WidgetHostNative}; use op_editor_ui::widgets::{ - rotation_corner_at_point, selection_handle_at_point, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, - TOP_BAR_HEIGHT, + rotation_corner_at_point, selection_handle_at_point, AIChatPlaceholder, ChatResizeEdge, + STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, TOP_BAR_HEIGHT, }; use op_editor_ui::{Point2D, Rect}; @@ -58,6 +58,27 @@ impl WidgetHostNative { self.panel_resize.is_some() } + pub fn chat_resize_hover( + &self, + x: f32, + y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> Option { + let rect = self.ai_chat_rect(viewport_w, viewport_h)?; + let panel = AIChatPlaceholder::from_editor(&self.editor_state); + panel.resize_edge_at(rect, Point2D::new(x, y)) + } + + fn chat_resize_cursor(edge: ChatResizeEdge) -> CursorHint { + match edge { + ChatResizeEdge::E | ChatResizeEdge::W => CursorHint::ResizeEw, + ChatResizeEdge::N | ChatResizeEdge::S => CursorHint::ResizeNs, + ChatResizeEdge::Nw | ChatResizeEdge::Se => CursorHint::ResizeNwse, + ChatResizeEdge::Ne | ChatResizeEdge::Sw => CursorHint::ResizeNesw, + } + } + /// Clear every lower-overlay hover highlight — file menu, locale /// / shape pickers, align toolbar, layer-context menu. Called /// when the cursor moves over the top-most Design-MD panel so a @@ -356,6 +377,12 @@ impl WidgetHostNative { if self.over_disabled_init_card(x, y, viewport_w, viewport_h) { return CursorHint::NotAllowed; } + if let Some(resize) = self.chat_resize { + return Self::chat_resize_cursor(resize.edge); + } + if let Some(edge) = self.chat_resize_hover(x, y, viewport_w, viewport_h) { + return Self::chat_resize_cursor(edge); + } // Any floating overlay (panels, Git popover, Toolbar / // StatusBar / chat, open dropdowns) — a neutral cursor over // them, never a canvas action cursor (Move / Crosshair) diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 1e6bf894a..dbd5c5aac 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -2,6 +2,9 @@ use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH}; use super::{PanelResizeKind, WidgetHostNative}; +use op_editor_ui::widgets::{ + ChatResizeEdge, AI_CHAT_MAX_RATIO, AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH, +}; use op_editor_ui::{Point2D, Rect}; /// Minimum cursor travel (logical px) from the node-drag press point @@ -536,6 +539,95 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if let Some(resize) = self.chat_resize { + let (cx0, cy0, cw, ch) = self.canvas_region(self.last_viewport_w, self.last_viewport_h); + let max_w = (cw * AI_CHAT_MAX_RATIO).max(AI_CHAT_MIN_WIDTH); + let max_h = (ch * AI_CHAT_MAX_RATIO).max(AI_CHAT_MIN_HEIGHT); + let dx = x - resize.start_x; + let dy = y - resize.start_y; + let mut new_w = resize.start_rect.size.x; + let mut new_h = resize.start_rect.size.y; + let mut new_left = resize.start_rect.origin.x; + let mut new_top = resize.start_rect.origin.y; + + if matches!( + resize.edge, + ChatResizeEdge::E | ChatResizeEdge::Ne | ChatResizeEdge::Se + ) { + new_w = resize.start_rect.size.x + dx; + } + if matches!( + resize.edge, + ChatResizeEdge::W | ChatResizeEdge::Nw | ChatResizeEdge::Sw + ) { + new_w = resize.start_rect.size.x - dx; + new_left = resize.start_rect.origin.x + dx; + } + if matches!( + resize.edge, + ChatResizeEdge::S | ChatResizeEdge::Se | ChatResizeEdge::Sw + ) { + new_h = resize.start_rect.size.y + dy; + } + if matches!( + resize.edge, + ChatResizeEdge::N | ChatResizeEdge::Ne | ChatResizeEdge::Nw + ) { + new_h = resize.start_rect.size.y - dy; + new_top = resize.start_rect.origin.y + dy; + } + + if new_w < AI_CHAT_MIN_WIDTH { + let diff = AI_CHAT_MIN_WIDTH - new_w; + new_w = AI_CHAT_MIN_WIDTH; + if matches!( + resize.edge, + ChatResizeEdge::W | ChatResizeEdge::Nw | ChatResizeEdge::Sw + ) { + new_left -= diff; + } + } + if new_w > max_w { + let diff = new_w - max_w; + new_w = max_w; + if matches!( + resize.edge, + ChatResizeEdge::W | ChatResizeEdge::Nw | ChatResizeEdge::Sw + ) { + new_left += diff; + } + } + if new_h < AI_CHAT_MIN_HEIGHT { + let diff = AI_CHAT_MIN_HEIGHT - new_h; + new_h = AI_CHAT_MIN_HEIGHT; + if matches!( + resize.edge, + ChatResizeEdge::N | ChatResizeEdge::Ne | ChatResizeEdge::Nw + ) { + new_top -= diff; + } + } + if new_h > max_h { + let diff = new_h - max_h; + new_h = max_h; + if matches!( + resize.edge, + ChatResizeEdge::N | ChatResizeEdge::Ne | ChatResizeEdge::Nw + ) { + new_top += diff; + } + } + + let max_left = cx0 + cw - new_w; + let max_top = cy0 + ch - new_h; + new_left = new_left.clamp(cx0, max_left.max(cx0)); + new_top = new_top.clamp(cy0, max_top.max(cy0)); + self.editor_state.chat.panel_width = new_w.round(); + self.editor_state.chat.panel_height = new_h.round(); + self.editor_state.chat.panel_position = Some((new_left.round(), new_top.round())); + self.mark_dirty(); + return true; + } if let Some(d) = self.chat_drag.as_mut() { d.pos_x = x - d.grab_dx; d.pos_y = y - d.grab_dy; @@ -584,6 +676,9 @@ impl WidgetHostNative { if self.panel_resize.take().is_some() { return true; } + if self.chat_resize.take().is_some() { + return true; + } if self.rotate_drag.take().is_some() { return true; } @@ -644,6 +739,7 @@ impl WidgetHostNative { let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h); self.editor_state.chat.anchor = op_editor_core::ChatAnchor::nearest(center, cx0, cy0, cw, ch); + self.editor_state.chat.panel_position = None; self.mark_dirty(); return true; } @@ -657,6 +753,9 @@ impl WidgetHostNative { if self.panel_resize.take().is_some() { return true; } + if self.chat_resize.take().is_some() { + return true; + } if self.rotate_drag.take().is_some() { return true; } diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index e65bfefd8..6f9892b50 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -5,7 +5,7 @@ //! `host.editor_state` from canonical-schema JSON and assert against //! `editor_state` + the derived `LayoutScene` render scene. -use super::WidgetHostNative; +use super::{CursorHint, WidgetHostNative}; use op_editor_core::ui_draft::PropertyFocus; use op_editor_core::NodeId; use op_editor_core::PenNodeExt; @@ -76,6 +76,40 @@ fn ai_chat_new_chat_click_clears_transcript_and_queues_abort() { assert!(host.editor_state().chat.pending_new_chat); } +#[test] +fn ai_chat_east_edge_shows_resize_cursor() { + let host = WidgetHostNative::new(); + let rect = host + .ai_chat_rect(1200.0, 800.0) + .expect("chat panel visible"); + let x = rect.origin.x + rect.size.x - 2.0; + let y = rect.origin.y + rect.size.y / 2.0; + + assert_eq!(host.cursor_hint(x, y, 1200.0, 800.0), CursorHint::ResizeEw); +} + +#[test] +fn ai_chat_east_edge_drag_resizes_panel_width() { + let mut host = WidgetHostNative::new(); + let before = host + .ai_chat_rect(1200.0, 800.0) + .expect("chat panel visible"); + let x = before.origin.x + before.size.x - 2.0; + let y = before.origin.y + before.size.y / 2.0; + + assert!(host.apply_press(x, y, 1200.0, 800.0)); + assert!(host.apply_cursor_move(x + 72.0, y)); + + let after = host + .ai_chat_rect(1200.0, 800.0) + .expect("chat panel visible after resize"); + assert!( + after.size.x > before.size.x + 60.0, + "dragging the east edge should grow chat width; before={before:?}, after={after:?}" + ); + assert_eq!(after.origin.x, before.origin.x); +} + #[test] fn ai_chat_stop_click_keeps_transcript_and_queues_abort() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index c9f20ec43..35a1f8e67 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -9,8 +9,8 @@ use super::helpers::{rect_contains, TOOLBAR_INSET_X, TOOLBAR_INSET_Y}; use super::{ - ChatDragState, CreateDragState, DragState, HandleDragState, NodeDragState, PanelResize, - PanelResizeKind, RotateDragState, WidgetHostNative, + ChatDragState, ChatResizeState, CreateDragState, DragState, HandleDragState, NodeDragState, + PanelResize, PanelResizeKind, RotateDragState, WidgetHostNative, }; use op_editor_ui::widgets::{ rotation_corner_at_point, selection_handle_at_point, AIChatHit, AIChatPlaceholder, LayoutCx, @@ -530,6 +530,17 @@ impl WidgetHostNative { if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) { let panel = AIChatPlaceholder::from_editor(&self.editor_state); if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) { + if let AIChatHit::Resize(edge) = hit { + self.chat_resize = Some(ChatResizeState { + edge, + start_x: x, + start_y: y, + start_rect: chat_rect, + }); + self.editor_state.chat.focused = false; + self.mark_dirty(); + return true; + } if matches!(hit, AIChatHit::DragHandle) { self.chat_drag = Some(ChatDragState { grab_dx: x - chat_rect.origin.x, diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index d59e80ce7..6608922c6 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -405,6 +405,9 @@ impl WidgetHost { if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) { let panel = AIChatPlaceholder::from_editor_at(&self.editor_state, self.now_ms); if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) { + if matches!(hit, AIChatHit::Resize(_)) { + return true; + } if matches!(hit, AIChatHit::DragHandle) { self.chat_drag = Some(ChatDragState { grab_dx: x - chat_rect.origin.x, @@ -627,6 +630,9 @@ impl WidgetHost { AIChatHit::DragHandle => { return false; } + AIChatHit::Resize(_) => { + return false; + } AIChatHit::ToggleCollapse => { let v = &mut self.editor_state.chat.collapsed; *v = !*v;