diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index a1e6c351d..07e696b1e 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -231,6 +231,7 @@ mod tests { } fn screen(cx: &mut TestAppContext) -> gpui::Entity { + cx.executor().allow_parking(); let backend = Arc::new( AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 966654cfb..21154e2c6 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -185,75 +185,101 @@ impl ChatScreen { }), ), ) - .when(collapsed_progress > 0., |row| { - row.child( - div() - .absolute() - .left(toolbar_left + toolbar_width + header_gap) - .top((super::CHAT_HEADER_HEIGHT - window.rem_size()) / 2.) - .w(px(1.)) - .h_4() - .opacity(collapsed_progress) - .bg(gpui::rgb(theme::border())), - ) - }) - .child( - div() - .debug_selector(|| "header-title".to_string()) - // Sized by its text, like the chips: nowrap gives it a - // real intrinsic width (a clamped, shrinkable title - // measured as 0 px and vanished). The cap keeps a long - // title from pushing the chips out of the pane. - .flex_none() - .max_w(gpui::relative(0.6)) - .truncate() - .text_lg() - .line_height(px(24.)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_primary())) - .child(title), - ) - .child( - self.with_menu( - ChatPopup::Project, - chip( - "root-picker", - Some(if self.backend.remote_host().is_some() { - "globe" - } else { - "folder-open" - }), - self.project_label.clone(), - true, - self.popup.is_open(&ChatPopup::Project), - false, + .when( + collapsed_progress > 0. && self.selected_session.is_some(), + |row| { + row.child( + div() + .absolute() + .left(toolbar_left + toolbar_width + header_gap) + .top((super::CHAT_HEADER_HEIGHT - window.rem_size()) / 2.) + .w(px(1.)) + .h_4() + .opacity(collapsed_progress) + .bg(gpui::rgb(theme::border())), ) - // The header is a window drag region; a press here is the - // chip's. - .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { - cx.stop_propagation(); - }), - Placement::BelowStart, - window, - cx, - Self::project_menu, - ) - .flex_none(), + }, ) - .when_some(self.branch_label.clone(), |row, branch| { + .when(self.selected_session.is_some(), |row| { row.child( div() + .debug_selector(|| "header-title".to_string()) + // Sized by its text, like the chips: nowrap gives it a + // real intrinsic width (a clamped, shrinkable title + // measured as 0 px and vanished). The cap keeps a long + // title from pushing the chips out of the pane. .flex_none() - .text_xs() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::status_error())) - .whitespace_nowrap() - .child(branch), + .max_w(gpui::relative(0.6)) + .truncate() + .text_lg() + .line_height(px(24.)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_primary())) + .child(title), ) + .child(self.render_project_context(Placement::BelowStart, window, cx)) }) .child(div().flex_1()) } + fn render_project_context( + &self, + placement: Placement, + window: &mut Window, + cx: &mut Context, + ) -> Div { + div() + .flex() + .items_center() + .gap_3() + .min_w_0() + .max_w_full() + .child( + self.with_menu( + ChatPopup::Project, + chip( + "root-picker", + Some(if self.backend.remote_host().is_some() { + "globe" + } else { + "folder-open" + }), + self.project_label.clone(), + true, + self.popup.is_open(&ChatPopup::Project), + false, + ) + // The header is a window drag region; a press here is the + // chip's. + .on_mouse_down( + gpui::MouseButton::Left, + |_event, _window, cx| { + cx.stop_propagation(); + }, + ), + placement, + window, + cx, + Self::project_menu, + ) + .min_w_0() + .max_w_full(), + ) + .when_some(self.branch_label.clone(), |row, branch| { + row.child( + div() + .flex_none() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::status_error())) + .max_w(px(200.)) + .truncate() + .debug_selector(|| "project-branch".to_string()) + .child(branch), + ) + }) + } + /// `button` as the opener of `popup`, with `menu` attached below or /// above it while the popup is open. fn with_menu( @@ -280,7 +306,7 @@ impl ChatScreen { .children(menu) } - /// A press on a chip: the header's project chip runs the command it + /// A press on a chip: the project chip runs the command it /// shares with its shortcut; a composer chip opens or closes its menu. fn press_chip(&mut self, popup: ChatPopup, window: &mut Window, cx: &mut Context) { match popup { @@ -1125,7 +1151,7 @@ impl ChatScreen { let upload = self .selected_upload() .map(|(id, upload)| (id.to_string(), upload.label.clone())); - div() + let composer_box = div() .w_full() .flex() .flex_col() @@ -1402,7 +1428,21 @@ impl ChatScreen { }) }) }), + ); + if self.selected_session.is_some() { + return composer_box; + } + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .when(expanded, |container| container.flex_1().min_h_0()) + .child( + self.render_project_context(Placement::AboveStart, window, cx) + .flex_none(), ) + .child(composer_box) } } @@ -1499,7 +1539,13 @@ fn chip( }) .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .children(leading.map(|name| icon(name, px(16.), color))) - .child(div().whitespace_nowrap().child(label)) + .when(id == "root-picker", |el| el.max_w(px(260.)).min_w_0()) + .child( + div() + .whitespace_nowrap() + .when(id == "root-picker", |label| label.min_w_0().truncate()) + .child(label), + ) .when(chevron, |el| el.child(icon("chevron-down", px(14.), color))) } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index cbfb7272d..74dddaed5 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -7130,12 +7130,148 @@ mod state_tests { assert_eq!(cx.debug_bounds("sidebar-wordmark"), Some(wordmark)); } + /// Draft project context stays with the composer in both pane layouts, + /// while the shared trigger keeps pointer and shortcut menu behavior. + #[gpui::test] + fn draft_project_picker_stays_above_composer(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); + let (chat, cx) = chat_window(cx, |this, _| { + this.selected_session = None; + this.project_label = + "A very long project name that should stay inside the composer context row".into(); + this.branch_label = Some("(feature/draft-context)".into()); + }); + for has_history in [false, true] { + for collapsed in [false, true] { + for expanded in [false, true] { + for pending_create in [false, true] { + chat.update(cx, |this, cx| { + this.session_setup_pending = pending_create; + this.replace_timeline(if has_history { + vec![user_item("u1", "hi")] + } else { + Vec::new() + }); + this.composer_expanded = expanded; + this.set_sidebar_collapsed(collapsed, cx); + cx.notify(); + }); + cx.run_until_parked(); + let project = cx.debug_bounds("root-picker").expect("draft project"); + let branch = cx.debug_bounds("project-branch").expect("draft branch"); + let composer = cx.debug_bounds("composer-box").expect("composer"); + let header = cx.debug_bounds("chat-header").expect("header"); + assert!(cx.debug_bounds("header-title").is_none()); + assert!( + project.top() >= header.bottom(), + "history={has_history}, collapsed={collapsed}, expanded={expanded}, pending={pending_create}: project={project:?}, header={header:?}, composer={composer:?}" + ); + assert_eq!(project.left(), composer.left()); + assert!(project.bottom() <= composer.top()); + assert!(composer.top() - project.bottom() <= px(12.)); + assert!(project.size.width <= px(260.)); + assert!(branch.right() <= composer.right()); + assert!((branch.center().y - project.center().y).abs() <= px(0.5)); + assert!(composer.size.height > px(0.)); + if expanded { + assert!( + composer.size.height > px(300.), + "expanded composer: {composer:?}" + ); + } + + press(cx, "root-picker"); + assert_eq!(open_menu(&chat, cx), "Project"); + let menu = cx.debug_bounds("project-menu").expect("project menu"); + assert!(!menu.contains(&project.center())); + cx.simulate_keystrokes("escape"); + assert_eq!(open_menu(&chat, cx), ""); + cx.simulate_keystrokes("secondary-p"); + assert_eq!(open_menu(&chat, cx), "Project"); + cx.simulate_keystrokes("escape"); + } + } + } + } + } + + #[gpui::test] + fn draft_project_context_fits_a_narrow_window(cx: &mut TestAppContext) { + struct ChatHost { + chat: Entity, + } + impl Render for ChatHost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().flex().flex_col().child(self.chat.clone()) + } + } + cx.executor().allow_parking(); + cx.update(|cx| cx.set_reduce_motion(true)); + let chat = cx.new(|cx| { + let _guard = SETTINGS_LOCK.lock(); + crate::desktop::register_key_bindings(cx); + let backend = Arc::new( + AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap(), + ); + let mut chat = ChatScreen::new_without_start(backend, "user".into(), cx); + chat.booting = false; + chat.trust_prompts = false; + chat.project_label = "A long project name that must fit beside its branch".into(); + chat.branch_label = Some("(feature/a-long-branch-name-that-must-also-fit)".into()); + chat.sidebar + .update(cx, |sidebar, _| sidebar.reset_persisted_for_test()); + chat + }); + let (_host, cx) = cx.add_window_view(|_, _| ChatHost { chat: chat.clone() }); + cx.simulate_resize(gpui::size(px(640.), px(480.))); + cx.update(|window, _| window.activate_window()); + for has_history in [false, true] { + for collapsed in [false, true] { + for expanded in [false, true] { + chat.update(cx, |this, cx| { + this.replace_timeline(if has_history { + vec![user_item("u1", "hi")] + } else { + Vec::new() + }); + this.composer_expanded = expanded; + this.set_sidebar_collapsed(collapsed, cx); + cx.notify(); + }); + cx.run_until_parked(); + let project = cx.debug_bounds("root-picker").expect("project"); + let branch = cx.debug_bounds("project-branch").expect("branch"); + let composer = cx.debug_bounds("composer-box").expect("composer"); + assert_eq!(project.left(), composer.left()); + assert!( + project.right() <= composer.right() && branch.right() <= composer.right(), + "history={has_history}, collapsed={collapsed}, expanded={expanded}: project={project:?}, branch={branch:?}, composer={composer:?}" + ); + assert!(project.bottom() <= composer.top()); + assert!(composer.top() - project.bottom() <= px(12.)); + assert!(composer.size.height > px(0.)); + assert!(composer.bottom() <= px(480.)); + assert!(cx.debug_bounds("header-title").is_none()); + press(cx, "root-picker"); + assert_eq!(open_menu(&chat, cx), "Project"); + let menu = cx.debug_bounds("project-menu").expect("project menu"); + assert!(!menu.contains(&project.center())); + assert!(menu.left() >= px(0.) && menu.right() <= px(640.)); + cx.simulate_keystrokes("escape"); + } + } + } + } + /// The collapsed header keeps its commands before the title in both /// pane layouts; expanding keeps only the same fixed toggle. #[gpui::test] fn collapsed_sidebar_header_toolbar(cx: &mut TestAppContext) { cx.update(|cx| cx.set_reduce_motion(true)); let (chat, cx) = chat_window(cx, |this, cx| { + this.branch_label = Some("(main)".into()); + this.selected_session = Some("s1".into()); + this.selected_title = "One".into(); this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); }); @@ -7159,6 +7295,15 @@ mod state_tests { ); assert_eq!(toggle.left(), SIDEBAR_RAIL_WIDTH + SIDEBAR_COLLAPSED_INSET); assert!(toggle.right() < new_task.left()); + assert!( + cx.debug_bounds("chat-header") + .unwrap() + .contains(&project.center()) + ); + assert!( + (cx.debug_bounds("project-branch").unwrap().center().y - project.center().y).abs() + <= px(0.5) + ); assert!(title.right() < project.left()); assert_eq!(toggle.center().y, title.center().y);