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:
11 files changed
+1349
-9
No files matched your search
@@ -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 |
@@ -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 |
@@ -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))
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use gpui::{
|
||||
|
||||
mod cite;
|
||||
mod compose;
|
||||
mod history;
|
||||
|
||||
use super::{
|
||||
ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user