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 6478dc4242
commit 280d49a96c
11 files changed
+1349 -9

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-corner-down-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 10 5 5-5 5" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-square-pen"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
</svg>

After

Width:  |  Height:  |  Size: 498 B

+344
View File
@@ -0,0 +1,344 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
chat::{
Conversation, ConversationExport, ConversationList, MessageAvatar, MessageBubble,
MessageList, Project, ProjectKnowledgePanel, ProjectList, Role, SharedConversationView,
StreamingMarkdown, new_chat_button,
},
feedback::{Toast, Toaster},
forms::{SearchInput, TextInput},
overlays::PromptDialog,
primitives::{IconName, Severity},
theme::{ActiveTheme, Radius},
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::{Timestamp, ToSpan, tz::TimeZone};
use crate::{
probe::probe,
ui::{keep, section, set},
};
/// The conversations and the one open, shared by the list and the shared view.
pub struct History {
pub all: Entity<Vec<Conversation>>,
pub active: Entity<Option<SharedString>>,
}
pub fn history(window: &mut Window, cx: &mut App) -> History {
History {
all: keep(
"chat-conversations",
|| conversations(Timestamp::now()),
window,
cx,
),
active: keep(
"chat-conversation",
|| Some(SharedString::from("lift")),
window,
cx,
),
}
}
fn conversations(now: Timestamp) -> Vec<Conversation> {
[
("lift", "What a lift does to a color", 0, false),
("gamma", "Gamma and the eye", 3, true),
("dark", "Designing the dark theme", 26, false),
("type", "A type scale for the library", 30, false),
("motion", "Durations that settle", 120, false),
("icons", "Choosing an icon set", 200, true),
("grid", "Spacing on a rem scale", 400, false),
("release", "Release notes for 1.0", 2000, false),
]
.into_iter()
.map(|(key, title, hours, pinned)| Conversation {
key: key.into(),
title: title.into(),
at: now - (hours as i64).hours(),
pinned,
})
.collect()
}
/// Adds `conversation` at the top unless its key is there, and opens it.
fn open_new(history: &History, conversation: Conversation, cx: &mut App) {
let key = conversation.key.clone();
let mut next = history.all.read(cx).clone();
if !next.iter().any(|item| item.key == key) {
next.insert(0, conversation);
set(&history.all, next, cx);
}
set(&history.active, Some(key), cx)
}
pub fn conversations_section(
history: &History,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement + use<> {
let today = Timestamp::now().to_zoned(TimeZone::system()).date();
let query = window.use_keyed_state("chat-find-conversation", cx, |window, cx| {
TextInput::new(window, cx).placeholder("Search conversations")
});
let naming = window.use_keyed_state("chat-rename-field", cx, |window, cx| {
TextInput::new(window, cx)
});
let renaming = keep("chat-renaming", || None::<SharedString>, window, cx);
let (now_all, now_active, now_query, now_renaming) = (
history.all.read(cx).clone(),
history.active.read(cx).clone(),
query.read(cx).text().to_string(),
renaming.read(cx).clone(),
);
let (all, active) = (history.all.clone(), history.active.clone());
let (fresh, picked, pinned, deleted, renamed) = (
History {
all: all.clone(),
active: active.clone(),
},
active.clone(),
all.clone(),
all.clone(),
all.clone(),
);
let (asked, field, shut, done) = (renaming.clone(), naming.clone(), renaming.clone(), renaming);
let theme = cx.theme();
section(
"ConversationList / ConversationItem / ConversationSearch / NewChatButton",
"Conversations down a sidebar, pinned first, then by day; the one open marked with its menu, the others show theirs on hover or focus. The search is a forms::SearchInput; the list keeps titles that hold every word.",
cx,
)
.child(probe(
"chat-history",
div()
.w(px(300.))
.h(px(520.))
.flex()
.flex_col()
.gap_2()
.p_2()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.sunken)
.child(new_chat_button("chat-new").on_click(move |_, _, cx| {
let at = Timestamp::now();
let key = SharedString::from(format!("new-{}", at.as_millisecond()));
open_new(&fresh, Conversation { key, title: "New chat".into(), at, pinned: false }, cx)
}))
.child(SearchInput::new("chat-find", &query))
.child(
div().flex_1().min_h_0().child(
ConversationList::new("chat-list", now_all, today)
.query(now_query)
.active(now_active, move |key, _, cx| set(&picked, Some(key.clone()), cx))
.actions(
move |key, _, cx| {
let title = all.read(cx).iter().find(|item| &item.key == key).map(|item| item.title.clone());
let title = title.expect("a listed conversation has a title");
field.update(cx, |input, cx| input.set_text(title, cx));
set(&asked, Some(key.clone()), cx)
},
move |key, _, cx| {
let mut next = pinned.read(cx).clone();
let item = next.iter_mut().find(|item| &item.key == key).expect("a listed conversation");
item.pinned = !item.pinned;
set(&pinned, next, cx)
},
move |key, _, cx| {
let mut next = deleted.read(cx).clone();
next.retain(|item| &item.key != key);
set(&deleted, next, cx)
},
),
),
),
))
.children(now_renaming.map(|key| {
PromptDialog::new("chat-rename", "Rename conversation", &naming, move |_, cx| set(&shut, None, cx))
.label("Title")
.submit("Rename")
.check(|text| match text.trim() {
"" => Err("A title cannot be empty.".into()),
_ => Ok(()),
})
.on_submit(move |text, _, cx| {
let mut next = renamed.read(cx).clone();
let item = next.iter_mut().find(|item| item.key == key).expect("the conversation being renamed");
item.title = text.trim().to_string().into();
set(&renamed, next, cx);
set(&done, None, cx)
})
}))
}
pub fn projects_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let active = keep(
"chat-project",
|| Some(SharedString::from("library")),
window,
cx,
);
let files = keep(
"chat-knowledge",
|| {
vec![
(SharedString::from("lift-notes.pdf"), 482_000_u64),
(SharedString::from("palette.json"), 12_400),
(SharedString::from("type-scale.md"), 6_800),
]
},
window,
cx,
);
let (now_active, now_files) = (active.read(cx).clone(), files.read(cx).clone());
let (added, removed) = (files.clone(), files);
section(
"FolderList / ProjectList / ProjectKnowledgePanel",
"Projects that hold conversations, each with how many; and what a project knows beyond the conversation, how full that knowledge is, with a way to add more.",
cx,
)
.child(
div()
.flex()
.items_start()
.gap_6()
.child(
div().w(px(240.)).child(
ProjectList::new(
"chat-projects",
[
Project { key: "library".into(), name: "Component library".into(), count: 24 },
Project { key: "site".into(), name: "The website".into(), count: 9 },
Project { key: "notes".into(), name: "Color notes".into(), count: 3 },
],
)
.active(now_active, move |key, _, cx| set(&active, Some(key.clone()), cx)),
),
)
.child(
div().w(px(360.)).child(
ProjectKnowledgePanel::new("chat-knowledge", now_files, 2_000_000)
.on_add(move |_, cx| {
let mut next = added.read(cx).clone();
next.push((format!("reading-{}.md", next.len() + 1).into(), 3_200));
set(&added, next, cx)
})
.on_remove(move |ix, _, cx| {
let mut next = removed.read(cx).clone();
next.remove(ix);
set(&removed, next, cx)
}),
),
),
)
}
const SHARED: [(&str, &str); 4] = [
("What does a lift do to a color?", ""),
(
"",
"A **lift** blends a color toward white. Half a lift sits midway between the color and white.",
),
("And a shade?", ""),
(
"",
"A **shade** blends toward black the same way. Dark themes build their surfaces from shades.",
),
];
const FORMATS: [(&str, &str); 3] = [("markdown", "Markdown"), ("pdf", "PDF"), ("json", "JSON")];
pub fn shared_section(
history: &History,
toaster: &Entity<Toaster>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement + use<> {
let exporting = keep("chat-exporting", || false, window, cx);
let format = keep(
"chat-export-format",
|| SharedString::from("markdown"),
window,
cx,
);
let thinking = keep("chat-export-thinking", || false, window, cx);
let (open, now_format, now_thinking) = (
*exporting.read(cx),
format.read(cx).clone(),
*thinking.read(cx),
);
let (opener, closer) = (exporting.clone(), exporting);
let (carried, toaster) = (
History {
all: history.all.clone(),
active: history.active.clone(),
},
toaster.clone(),
);
let messages = MessageList::new("chat-shared-messages", SHARED.len(), |ix, _, _| {
let (asked, answered) = SHARED[ix];
let bubble = if asked.is_empty() {
MessageBubble::new(Role::Assistant)
.avatar(MessageAvatar::new(
("chat-shared-mark", ix),
Role::Assistant,
"Assistant",
))
.child(StreamingMarkdown::new(
("chat-shared-text", ix),
answered,
false,
))
} else {
MessageBubble::new(Role::User).child(asked)
};
div().px_6().py_3().child(bubble).into_any_element()
});
let theme = cx.theme();
section(
"SharedConversationView / ConversationExport",
"A conversation someone shared, to read and carry on; and saving one to a file, in a format, with or without its thinking.",
cx,
)
.child(
div()
.w(px(640.))
.h(px(300.))
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.overflow_hidden()
.child(
SharedConversationView::new("chat-shared", "Grace Lin", Timestamp::now() - 3.hours(), messages).on_continue(move |_, cx| {
let conversation = Conversation { key: "shared-lift".into(), title: SHARED[0].0.into(), at: Timestamp::now(), pinned: false };
open_new(&carried, conversation, cx)
}),
),
)
.child(probe(
"chat-export-open",
Button::new("chat-export", "Export…")
.variant(ButtonVariant::Secondary)
.icon(IconName::Download)
.on_click(move |_, _, cx| set(&opener, true, cx)),
))
.children(open.then(|| {
let label = FORMATS.iter().find(|(value, _)| *value == now_format.as_ref()).expect("a known format").1;
ConversationExport::new(
"chat-export-dialog",
now_format,
move |next, _, cx| set(&format, next.clone(), cx),
move |_, cx| {
toaster.update(cx, |toaster, cx| {
toaster.push(Toast::new(format!("Exported as {label}")).severity(Severity::Success), cx);
})
},
move |_, cx| set(&closer, false, cx),
)
.thinking(now_thinking, move |on, _, cx| set(&thinking, on, cx))
}))
}
+17 -1
View File
@@ -2,12 +2,14 @@ mod blocks;
mod cite;
mod compose;
mod controls;
mod history;
mod talk;
use ely_gpui_component::feedback::{ToastViewport, Toaster};
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
use crate::{script::Step, ui::keep};
pub const PAGE: Page = Page {
number: 23,
@@ -54,12 +56,22 @@ const SCRIPT: &[Step] = &[
Step::UpAt("chat-system", 548.0, 12.0),
Step::Wait(200),
Step::Shot("reset"),
Step::HoverAt("chat-history", 150.0, 124.0),
Step::Wait(300),
Step::Shot("history"),
Step::Click("chat-export-open"),
Step::Wait(300),
Step::Shot("export"),
Step::Key("escape"),
Step::Wait(200),
Step::DownAt("chat-sources", 20.0, 10.0),
Step::UpAt("chat-sources", 20.0, 10.0),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
let draft = compose::draft(window, cx);
let history = history::history(window, cx);
let toaster = keep("chat-toaster", Toaster::default, window, cx);
div()
.child(talk::conversation(window, cx))
.child(talk::streaming(window, cx))
@@ -74,5 +86,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(compose::composer(&draft, window, cx))
.child(compose::pickers(&draft, window, cx))
.child(compose::tuning(window, cx))
.child(history::conversations_section(&history, window, cx))
.child(history::projects_section(window, cx))
.child(history::shared_section(&history, &toaster, window, cx))
.child(ToastViewport::new("chat-toasts", &toaster))
.into_any_element()
}
+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",
+8 -8
View File
@@ -145,14 +145,14 @@
## T23d AI Chat · Conversations
- [ ] ConversationList
- [ ] ConversationItem
- [ ] ConversationSearch
- [ ] NewChatButton
- [ ] FolderList / ProjectList
- [ ] ProjectKnowledgePanel
- [ ] SharedConversationView
- [ ] ConversationExport
- [x] ConversationList
- [x] ConversationItem
- [x] ConversationSearch → `forms::SearchInput` over `chat::ConversationList::query`
- [x] NewChatButton → `chat::new_chat_button`, a `buttons::Button`
- [x] FolderList / ProjectList → `chat::ProjectList`
- [x] ProjectKnowledgePanel
- [x] SharedConversationView
- [x] ConversationExport
## T23e AI Chat · Welcome