Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/maple-agent/app/src/ui/chat/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ mod tests {
}

fn screen(cx: &mut TestAppContext) -> gpui::Entity<ChatScreen> {
cx.executor().allow_parking();
let backend = Arc::new(
AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new())
.expect("backend"),
Expand Down
170 changes: 108 additions & 62 deletions apps/maple-agent/app/src/ui/chat/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>,
) -> 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(
Expand All @@ -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<Self>) {
match popup {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)))
}

Expand Down
145 changes: 145 additions & 0 deletions apps/maple-agent/app/src/ui/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatScreen>,
}
impl Render for ChatHost {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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);
});
Expand All @@ -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);

Expand Down
Loading