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,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()
|
||||
}
|
||||
Reference in new issue
Block a user