T23d: AI chat, conversations

- ConversationList: pinned first, then by day, newest first; a query keeps
  the titles that hold every word
- ConversationItem: a Tab stop when there is something to do; its menu of
  rename, pin and delete shows on hover, while open, or while the row holds
  focus; a press on the menu leaves the row shut
- new_chat_button, ProjectList, ProjectKnowledgePanel with a capacity meter
- SharedConversationView: who shared it and when, and a way to carry it on
- ConversationExport: format and thinking in a Dialog, Cancel or Export
- icons: square-pen, corner-down-right
- gallery: conversations with rename, pin and delete; projects; a shared
  conversation; export with a toast
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 07:32:48 -04:00
1 parent 247f836073
commit f3336b18bf
11 files changed
+1349 -9

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
transparent_black,
};
use jiff::{Timestamp, civil::Date, tz::TimeZone};
use crate::{
buttons::{Button, ButtonVariant},
forms::OnValue,
menus::{Menu, MenuItem, OverflowMenu},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::Ellipsis,
};
/// A conversation in a list: its key, its title, when it last moved, and whether it is pinned.
#[derive(Clone, Debug, PartialEq)]
pub struct Conversation {
pub key: SharedString,
pub title: SharedString,
pub at: Timestamp,
pub pinned: bool,
}
/// The heading a conversation files under by the day it last moved, seen from `today`.
pub(crate) fn group_name(day: Date, today: Date) -> String {
let days = (today - day).get_days();
match days {
..=0 => "Today".to_string(),
1 => "Yesterday".to_string(),
2..=7 => "Previous 7 days".to_string(),
8..=30 => "Previous 30 days".to_string(),
_ if day.year() == today.year() => day.strftime("%B").to_string(),
_ => day.strftime("%B %Y").to_string(),
}
}
/// Whether every word of `query` is in `title`, ignoring case.
pub(crate) fn matches(title: &str, query: &str) -> bool {
let title = title.to_lowercase();
query
.split_whitespace()
.all(|word| title.contains(&word.to_lowercase()))
}
/// The conversations `query` keeps, pinned first, then under their day's heading, newest first; each group holds indexes.
pub(crate) fn grouped(
conversations: &[Conversation],
query: &str,
today: Date,
zone: &TimeZone,
) -> Vec<(String, Vec<usize>)> {
let mut kept: Vec<usize> = (0..conversations.len())
.filter(|ix| matches(&conversations[*ix].title, query))
.collect();
kept.sort_by_key(|ix| std::cmp::Reverse(conversations[*ix].at));
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
let pinned: Vec<usize> = kept
.iter()
.copied()
.filter(|ix| conversations[*ix].pinned)
.collect();
if !pinned.is_empty() {
groups.push(("Pinned".to_string(), pinned));
}
for ix in kept.into_iter().filter(|ix| !conversations[*ix].pinned) {
let name = group_name(conversations[ix].at.to_zoned(zone.clone()).date(), today);
match groups.last_mut() {
Some((last, items)) if *last == name => items.push(ix),
_ => groups.push((name, vec![ix])),
}
}
groups
}
/// Opens a new conversation; it shows Command-N, which the host binds.
pub fn new_chat_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "New chat")
.variant(ButtonVariant::Secondary)
.icon(IconName::SquarePen)
.shortcut("secondary-n")
}
type OnKey = OnValue;
/// One conversation in the list: its title, a pin when pinned, the one open marked with its menu of rename, pin and delete, which the others show on hover or focus.
#[derive(IntoElement)]
pub struct ConversationItem {
id: ElementId,
conversation: Conversation,
active: bool,
on_select: Option<OnKey>,
on_rename: Option<OnKey>,
on_pin: Option<OnKey>,
on_delete: Option<OnKey>,
}
impl ConversationItem {
pub fn new(id: impl Into<ElementId>, conversation: Conversation) -> Self {
Self {
id: id.into(),
conversation,
active: false,
on_select: None,
on_rename: None,
on_pin: None,
on_delete: None,
}
}
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// Rename, pin and delete, each given the conversation's key.
pub fn actions(
mut self,
on_rename: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
on_pin: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
on_delete: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_rename = Some(Rc::new(on_rename));
self.on_pin = Some(Rc::new(on_pin));
self.on_delete = Some(Rc::new(on_delete));
self
}
}
impl RenderOnce for ConversationItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let key = self.conversation.key.clone();
let group = SharedString::from(format!("conversation-{}", self.id));
let menu = match (self.on_rename, self.on_pin, self.on_delete) {
(Some(rename), Some(pin), Some(delete)) => {
let (a, b, c) = (key.clone(), key.clone(), key.clone());
Some(
Menu::new()
.item(
MenuItem::new("Rename")
.icon(IconName::Pencil)
.on_click(move |window, cx| rename(&a, window, cx)),
)
.item(
match self.conversation.pinned {
true => MenuItem::new("Unpin").icon(IconName::PinOff),
false => MenuItem::new("Pin").icon(IconName::Pin),
}
.on_click(move |window, cx| pin(&b, window, cx)),
)
.separator()
.item(
MenuItem::new("Delete")
.icon(IconName::Trash2)
.on_click(move |window, cx| delete(&c, window, cx)),
),
)
}
_ => None,
};
let select = self.on_select;
let pressable = select.is_some() || menu.is_some();
let focus = tab_stop((self.id.clone(), "focus").into(), pressable, window, cx);
let shown = self.active || focus.contains_focused(window, cx);
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.id(self.id.clone())
.group(group.clone())
.track_focus(&focus)
.w_full()
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Md))
.pl_2()
.pr_0p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(transparent_black())
.focus_ring(cx)
.text_size(theme.text_size(TextSize::Sm))
.text_color(if self.active {
colors.fg
} else {
colors.fg_muted
})
.when(self.active, |row| row.bg(colors.active))
.when_some(select, |row, select| {
let key = key.clone();
row.cursor_pointer()
.when(!self.active, |row| row.hover(|row| row.bg(colors.hover)))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("conversations: open {key}");
select(&key, window, cx)
})
})
.when(self.conversation.pinned, |row| {
row.child(
Icon::new(IconName::Pin)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
})
.child(
div()
.flex_1()
.min_w_0()
.child(Ellipsis::new(self.conversation.title.clone())),
)
.children(menu.map(|menu| {
div()
.flex_none()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.when(!shown, |slot| {
slot.invisible().group_hover(group, |style| style.visible())
})
.child(
OverflowMenu::new((self.id.clone(), "menu"), menu)
.tooltip("Conversation actions"),
)
}))
}
}
/// Conversations down a column, pinned first, then under their day, newest first; a query keeps those whose titles hold every word.
#[derive(IntoElement)]
pub struct ConversationList {
id: ElementId,
conversations: Vec<Conversation>,
active: Option<SharedString>,
query: SharedString,
today: Date,
on_select: Option<OnKey>,
actions: Option<(OnKey, OnKey, OnKey)>,
}
impl ConversationList {
/// `today` names the reader's day, so tests and captures hold still.
pub fn new(
id: impl Into<ElementId>,
conversations: impl IntoIterator<Item = Conversation>,
today: Date,
) -> Self {
Self {
id: id.into(),
conversations: conversations.into_iter().collect(),
active: None,
query: SharedString::default(),
today,
on_select: None,
actions: None,
}
}
/// The conversation open, by key, and what a press on another asks.
pub fn active(
mut self,
active: Option<SharedString>,
on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.active = active;
self.on_select = Some(Rc::new(on_select));
self
}
/// Keeps the conversations whose titles hold every word of `query`, such as a `forms::SearchInput`'s.
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
/// Rename, pin and delete on each conversation.
pub fn actions(
mut self,
on_rename: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
on_pin: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
on_delete: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.actions = Some((Rc::new(on_rename), Rc::new(on_pin), Rc::new(on_delete)));
self
}
}
impl RenderOnce for ConversationList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let groups = grouped(
&self.conversations,
&self.query,
self.today,
&TimeZone::system(),
);
let empty = groups.is_empty();
div()
.id(self.id.clone())
.size_full()
.overflow_y_scroll()
.flex()
.flex_col()
.gap_3()
.p_2()
.when(empty, |list| {
list.child(
div()
.px_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child(if self.query.trim().is_empty() {
"No conversations yet"
} else {
"No conversations match"
}),
)
})
.children(groups.into_iter().map(|(name, items)| {
div()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.px_2()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_subtle)
.child(name),
)
.children(items.into_iter().map(|ix| {
let conversation = self.conversations[ix].clone();
let active = self.active.as_ref() == Some(&conversation.key);
let item = ConversationItem::new(
(self.id.clone(), format!("item-{}", conversation.key)),
conversation,
)
.active(active);
let item = match self.on_select.clone() {
Some(select) => {
item.on_select(move |key, window, cx| select(key, window, cx))
}
None => item,
};
match self.actions.clone() {
Some((rename, pin, delete)) => item.actions(
move |key, window, cx| rename(key, window, cx),
move |key, window, cx| pin(key, window, cx),
move |key, window, cx| delete(key, window, cx),
),
None => item,
}
}))
}))
}
}
#[cfg(test)]
mod tests {
use jiff::{ToSpan, civil::date};
use super::*;
fn conversation(key: &str, days_ago: i64, pinned: bool) -> Conversation {
let noon = date(2026, 9, 26)
.at(12, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.expect("noon exists");
Conversation {
key: key.to_string().into(),
title: format!("About {key}").into(),
at: (noon - days_ago.days()).timestamp(),
pinned,
}
}
#[test]
fn days_name_their_groups() {
let today = date(2026, 9, 26);
assert_eq!(group_name(today, today), "Today");
assert_eq!(group_name(date(2026, 9, 25), today), "Yesterday");
assert_eq!(group_name(date(2026, 9, 21), today), "Previous 7 days");
assert_eq!(group_name(date(2026, 9, 1), today), "Previous 30 days");
assert_eq!(group_name(date(2026, 3, 4), today), "March");
assert_eq!(group_name(date(2025, 12, 31), today), "December 2025");
}
#[test]
fn pinned_come_first_then_each_day_newest_first() {
let all = [
conversation("lift", 0, false),
conversation("gamma", 3, true),
conversation("dark", 1, false),
conversation("type", 0, false),
];
let groups = grouped(&all, "", date(2026, 9, 26), &TimeZone::UTC);
let names: Vec<(&str, Vec<usize>)> = groups
.iter()
.map(|(name, items)| (name.as_str(), items.clone()))
.collect();
assert_eq!(
names,
[
("Pinned", vec![1]),
("Today", vec![0, 3]),
("Yesterday", vec![2])
]
);
let found = grouped(&all, "ABOUT dark", date(2026, 9, 26), &TimeZone::UTC);
assert_eq!(found, [("Yesterday".to_string(), vec![2])]);
}
}
+6
View File
@@ -3,11 +3,13 @@ mod attach;
mod cite;
mod code;
mod composer;
mod conversations;
mod list;
mod media;
mod message;
mod params;
mod play;
mod projects;
mod search;
mod status;
mod stream;
@@ -23,6 +25,7 @@ pub use attach::{Attachment, AttachmentChip, ContextChips};
pub use cite::{CitationBadge, Source, SourceCard, SourceList};
pub use code::CodeBlock;
pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton};
pub use conversations::{Conversation, ConversationItem, ConversationList, new_chat_button};
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
pub use message::{
@@ -30,6 +33,9 @@ pub use message::{
};
pub use params::{CostEstimator, Parameter, ParameterPanel, SystemPromptEditor, TokenCounter};
pub use play::{AudioMessage, VideoMessage};
pub use projects::{
ConversationExport, Project, ProjectKnowledgePanel, ProjectList, SharedConversationView,
};
pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard};
pub use status::{
ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator,
+417
View File
@@ -0,0 +1,417 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, transparent_black,
};
use jiff::Timestamp;
use super::media::file_icon;
use crate::{
buttons::{Button, ButtonVariant, IconButton},
data_display::Meter,
forms::{CheckState, Checkbox, Choice, OnFlag, OnValue, Pick, RadioGroup, Run},
overlays::Dialog,
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Ellipsis, RelativeTime, format::file_size, tabular},
};
type OnKey = OnValue;
/// A project that holds conversations: its key, name, and how many it holds.
#[derive(Clone, Debug, PartialEq)]
pub struct Project {
pub key: SharedString,
pub name: SharedString,
pub count: usize,
}
/// Projects in a column, each with how many conversations it holds, the open one marked; a press opens one.
#[derive(IntoElement)]
pub struct ProjectList {
id: ElementId,
projects: Vec<Project>,
active: Option<SharedString>,
on_select: Option<OnKey>,
}
impl ProjectList {
pub fn new(id: impl Into<ElementId>, projects: impl IntoIterator<Item = Project>) -> Self {
Self {
id: id.into(),
projects: projects.into_iter().collect(),
active: None,
on_select: None,
}
}
pub fn active(
mut self,
active: Option<SharedString>,
on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.active = active;
self.on_select = Some(Rc::new(on_select));
self
}
}
impl RenderOnce for ProjectList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.w_full()
.flex()
.flex_col()
.gap_0p5()
.children(self.projects.into_iter().map(|project| {
let active = self.active.as_ref() == Some(&project.key);
let select = self.on_select.clone();
let key = project.key.clone();
div()
.id((self.id.clone(), format!("project-{key}")))
.flex()
.items_center()
.gap_2()
.h(theme.control_height(ControlSize::Md))
.px_2()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(transparent_black())
.text_size(theme.text_size(TextSize::Sm))
.when(active, |row| row.bg(colors.active))
.when_some(select, |row, select| {
row.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.when(!active, |row| row.hover(|row| row.bg(colors.hover)))
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
log::info!("projects: open {key}");
select(&key, window, cx)
})
})
.child(
Icon::new(IconName::Folder)
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(div().flex_1().min_w_0().child(Ellipsis::new(project.name)))
.child(
tabular(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle),
)
.child(project.count.to_string()),
)
}))
}
}
/// What a project knows beyond the conversation: its files, how full that knowledge is, a way to add more and to take one off.
#[derive(IntoElement)]
pub struct ProjectKnowledgePanel {
id: ElementId,
files: Vec<(SharedString, u64)>,
capacity: u64,
on_add: Option<Run>,
on_remove: Option<Pick>,
}
impl ProjectKnowledgePanel {
/// `files` by name and size; `capacity` is how much the project holds, in bytes.
pub fn new(
id: impl Into<ElementId>,
files: impl IntoIterator<Item = (impl Into<SharedString>, u64)>,
capacity: u64,
) -> Self {
assert!(capacity > 0, "a project holds something");
Self {
id: id.into(),
files: files
.into_iter()
.map(|(name, bytes)| (name.into(), bytes))
.collect(),
capacity,
on_add: None,
on_remove: None,
}
}
pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_add = Some(Rc::new(handler));
self
}
/// Gets the index of the file to take off.
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ProjectKnowledgePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let used: u64 = self.files.iter().map(|(_, bytes)| bytes).sum();
let share = used as f32 / self.capacity as f32;
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child("Project knowledge"),
)
.children(self.on_add.map(|add| {
Button::new((self.id.clone(), "add"), "Add files")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::Plus)
.on_click(move |_, window, cx| add(window, cx))
})),
)
.child(
Meter::new((self.id.clone(), "used"), "Capacity", share.min(1.0)).detail(format!(
"{} of {}",
file_size(used, false),
file_size(self.capacity, false)
)),
)
.child(
div()
.flex()
.flex_col()
.gap_0p5()
.children(
self.files
.into_iter()
.enumerate()
.map(|(ix, (name, bytes))| {
let remove = self.on_remove.clone();
div()
.flex()
.items_center()
.gap_2()
.py_1()
.text_size(theme.text_size(TextSize::Sm))
.child(
Icon::new(file_icon(&name))
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(div().flex_1().min_w_0().child(Ellipsis::new(name)))
.child(
tabular(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle),
)
.child(file_size(bytes, false)),
)
.children(remove.map(|remove| {
IconButton::new(
(self.id.clone(), format!("remove-{ix}")),
IconName::X,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(
move |_, window, cx| {
log::info!("project knowledge: remove {ix}");
remove(ix, window, cx)
},
)
}))
}),
),
)
}
}
/// A conversation someone shared, to read: who shared it and when, its messages as they stood, and a way to carry it on.
#[derive(IntoElement)]
pub struct SharedConversationView {
id: ElementId,
sharer: SharedString,
at: Timestamp,
messages: AnyElement,
on_continue: Option<Run>,
}
impl SharedConversationView {
/// `messages` show the conversation, such as a `MessageList`.
pub fn new(
id: impl Into<ElementId>,
sharer: impl Into<SharedString>,
at: Timestamp,
messages: impl IntoElement,
) -> Self {
Self {
id: id.into(),
sharer: sharer.into(),
at,
messages: messages.into_any_element(),
on_continue: None,
}
}
pub fn on_continue(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_continue = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SharedConversationView {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.size_full()
.flex()
.flex_col()
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::Link)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.child(format!("Shared by {}", self.sharer))
.child("·")
.child(RelativeTime::new((self.id.clone(), "when"), self.at))
.child(div().flex_1())
.children(self.on_continue.map(|carry| {
Button::new((self.id.clone(), "continue"), "Continue this conversation")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| carry(window, cx))
})),
)
.child(div().flex_1().min_h_0().child(self.messages))
}
}
const FORMATS: [(&str, &str); 3] = [("markdown", "Markdown"), ("pdf", "PDF"), ("json", "JSON")];
/// Saves a conversation to a file: the format, whether its thinking goes with it, then export or cancel. Render it while open.
#[derive(IntoElement)]
pub struct ConversationExport {
id: ElementId,
format: SharedString,
on_format: OnValue,
thinking: Option<(bool, OnFlag)>,
on_export: Run,
on_close: Run,
}
impl ConversationExport {
/// `format` is markdown, pdf or json; `on_close` runs on Cancel, Escape or a press outside.
pub fn new(
id: impl Into<ElementId>,
format: impl Into<SharedString>,
on_format: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
on_export: impl Fn(&mut Window, &mut App) + 'static,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
let format = format.into();
assert!(
FORMATS.iter().any(|(value, _)| *value == format.as_ref()),
"no export format {format}"
);
Self {
id: id.into(),
format,
on_format: Rc::new(on_format),
thinking: None,
on_export: Rc::new(on_export),
on_close: Rc::new(on_close),
}
}
/// Offers to include the thinking behind each answer.
pub fn thinking(
mut self,
included: bool,
on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
self.thinking = Some((included, Rc::new(on_change)));
self
}
}
impl RenderOnce for ConversationExport {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (pick, export, close) = (self.on_format, self.on_export, self.on_close.clone());
let format = self.format.clone();
let (cancel, done) = ((self.id.clone(), "cancel"), (self.id.clone(), "export"));
Dialog::new(self.id.clone(), "Export conversation", move |window, cx| {
close(window, cx)
})
.child(
div()
.flex()
.flex_col()
.gap_4()
.child(
RadioGroup::new(
(self.id.clone(), "format"),
FORMATS.map(|(value, label)| Choice::new(value, label)),
)
.selected(self.format)
.on_change(move |next, window, cx| {
log::info!("export: format {next}");
pick(next, window, cx)
}),
)
.children(self.thinking.map(|(included, change)| {
Checkbox::new((self.id.clone(), "thinking"), CheckState::from(included))
.label("Include the thinking behind each answer")
.on_change(move |on, window, cx| change(on, window, cx))
})),
)
.action(move |close| {
Button::new(cancel, "Cancel")
.variant(ButtonVariant::Secondary)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |close| {
Button::new(done, "Export")
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
log::info!("export: {format}");
export(window, cx);
close(window, cx)
})
})
}
}
+98
View File
@@ -0,0 +1,98 @@
use gpui::{
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, SharedString,
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use jiff::Timestamp;
use super::settle;
use crate::{
chat::{Conversation, ConversationItem},
forms,
theme::Theme,
};
/// One conversation's row, and what it asked: opened or renamed.
struct Row {
opened: Vec<SharedString>,
renamed: Vec<SharedString>,
}
impl Render for Row {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (open, rename) = (cx.entity(), cx.entity());
let conversation = Conversation {
key: "lift".into(),
title: "What a lift does".into(),
at: Timestamp::UNIX_EPOCH,
pinned: false,
};
div().w(px(300.0)).child(
ConversationItem::new("row", conversation)
.on_select(move |key, _, cx| open.update(cx, |row, _| row.opened.push(key.clone())))
.actions(
move |key, _, cx| rename.update(cx, |row, _| row.renamed.push(key.clone())),
|_, _, _| {},
|_, _, _| {},
),
)
}
}
fn row(cx: &mut TestAppContext) -> (gpui::Entity<Row>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
});
let (row, cx) = cx.add_window_view(|_, _| Row {
opened: Vec::new(),
renamed: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(row, cx)
}
/// Presses and releases `key`, a frame apart.
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
#[gpui::test]
fn a_rows_actions_open_from_the_keyboard(cx: &mut TestAppContext) {
let (row, cx) = row(cx);
cx.simulate_mouse_move(point(px(10.0), px(200.0)), None, Modifiers::none());
settle(cx);
cx.update(|window, _| window.focus_next());
settle(cx);
cx.update(|window, _| window.focus_next());
settle(cx);
press("enter", cx);
press("enter", cx);
row.read_with(cx, |row, _| {
assert_eq!(
row.renamed,
["lift"],
"Tab reaches the menu of the row in focus"
);
assert!(row.opened.is_empty());
});
}
#[gpui::test]
fn a_press_on_the_menu_leaves_the_row_shut(cx: &mut TestAppContext) {
let (row, cx) = row(cx);
let (title, menu) = (point(px(60.0), px(14.0)), point(px(285.0), px(14.0)));
cx.simulate_click(title, Modifiers::none());
settle(cx);
cx.simulate_mouse_move(menu, None, Modifiers::none());
settle(cx);
cx.simulate_click(menu, Modifiers::none());
settle(cx);
let opened = row.read_with(cx, |row, _| row.opened.clone());
assert_eq!(opened, ["lift"], "only the press on the title opens it");
}
+1
View File
@@ -9,6 +9,7 @@ use gpui::{
mod cite;
mod compose;
mod history;
use super::{
ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock,
+2
View File
@@ -109,6 +109,7 @@ icons! {
Compass => "compass",
Contrast => "contrast",
Copy => "copy",
CornerDownRight => "corner-down-right",
Cpu => "cpu",
CreditCard => "credit-card",
Crop => "crop",
@@ -249,6 +250,7 @@ icons! {
SquareCheck => "square-check",
Square => "square",
SquareFunction => "square-function",
SquarePen => "square-pen",
Star => "star",
StepForward => "step-forward",
Strikethrough => "strikethrough",