T23e: AI chat, welcome
- WelcomeScreen: a greeting for the hour over the owner's composer, starters and cards, centered while it fits and scrolled from its top when it does not - SuggestionChips, FollowUpSuggestions and CapabilityCards: each press asks the owner; rows and cards are Tab stops - PromptLibrary is documents::TemplatePicker over saved prompts - gallery: starters and cards fill the welcome composer, a saved prompt fills it too, send clears it with a toast, a follow-up asks its question
This commit is contained in:
7 files changed
+556
-5
No files matched your search
@@ -4,6 +4,7 @@ mod compose;
|
|||||||
mod controls;
|
mod controls;
|
||||||
mod history;
|
mod history;
|
||||||
mod talk;
|
mod talk;
|
||||||
|
mod welcome;
|
||||||
|
|
||||||
use ely_gpui_component::feedback::{ToastViewport, Toaster};
|
use ely_gpui_component::feedback::{ToastViewport, Toaster};
|
||||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||||
@@ -64,6 +65,9 @@ const SCRIPT: &[Step] = &[
|
|||||||
Step::Shot("export"),
|
Step::Shot("export"),
|
||||||
Step::Key("escape"),
|
Step::Key("escape"),
|
||||||
Step::Wait(200),
|
Step::Wait(200),
|
||||||
|
Step::Click("chat-follow-ups"),
|
||||||
|
Step::Wait(300),
|
||||||
|
Step::Shot("follow-up"),
|
||||||
Step::DownAt("chat-sources", 20.0, 10.0),
|
Step::DownAt("chat-sources", 20.0, 10.0),
|
||||||
Step::UpAt("chat-sources", 20.0, 10.0),
|
Step::UpAt("chat-sources", 20.0, 10.0),
|
||||||
];
|
];
|
||||||
@@ -72,6 +76,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
|||||||
let draft = compose::draft(window, cx);
|
let draft = compose::draft(window, cx);
|
||||||
let history = history::history(window, cx);
|
let history = history::history(window, cx);
|
||||||
let toaster = keep("chat-toaster", Toaster::default, window, cx);
|
let toaster = keep("chat-toaster", Toaster::default, window, cx);
|
||||||
|
let opening = welcome::welcome_draft(window, cx);
|
||||||
div()
|
div()
|
||||||
.child(talk::conversation(window, cx))
|
.child(talk::conversation(window, cx))
|
||||||
.child(talk::streaming(window, cx))
|
.child(talk::streaming(window, cx))
|
||||||
@@ -89,6 +94,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
|||||||
.child(history::conversations_section(&history, window, cx))
|
.child(history::conversations_section(&history, window, cx))
|
||||||
.child(history::projects_section(window, cx))
|
.child(history::projects_section(window, cx))
|
||||||
.child(history::shared_section(&history, &toaster, window, cx))
|
.child(history::shared_section(&history, &toaster, window, cx))
|
||||||
|
.child(welcome::welcome_section(&opening, &toaster, window, cx))
|
||||||
|
.child(welcome::follow_ups_section(window, cx))
|
||||||
|
.child(welcome::library_section(&opening, window, cx))
|
||||||
.child(ToastViewport::new("chat-toasts", &toaster))
|
.child(ToastViewport::new("chat-toasts", &toaster))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
use ely_gpui_component::{
|
||||||
|
chat::{
|
||||||
|
CapabilityCards, FollowUpSuggestions, MessageAvatar, MessageBubble, PromptInput, Role,
|
||||||
|
StreamingMarkdown, SuggestionChips, WelcomeScreen,
|
||||||
|
},
|
||||||
|
documents::{Template, TemplatePicker},
|
||||||
|
feedback::{Toast, Toaster},
|
||||||
|
forms::TextInput,
|
||||||
|
primitives::{IconName, Severity},
|
||||||
|
theme::{ActiveTheme, Radius},
|
||||||
|
};
|
||||||
|
use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, px};
|
||||||
|
use jiff::{Timestamp, tz::TimeZone};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
probe::probe,
|
||||||
|
ui::{keep, section, set},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The welcome composer's text, which starters and saved prompts fill.
|
||||||
|
pub fn welcome_draft(window: &mut Window, cx: &mut App) -> Entity<TextInput> {
|
||||||
|
window.use_keyed_state("chat-welcome-draft", cx, |window, cx| {
|
||||||
|
TextInput::new(window, cx)
|
||||||
|
.multi_line(1, 6)
|
||||||
|
.placeholder("How can I help?")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn welcome_section(
|
||||||
|
draft: &Entity<TextInput>,
|
||||||
|
toaster: &Entity<Toaster>,
|
||||||
|
_: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> impl IntoElement + use<> {
|
||||||
|
let zone = TimeZone::try_system().expect("the gallery reads the system time zone");
|
||||||
|
let hour = Timestamp::now().to_zoned(zone).hour() as u8;
|
||||||
|
let (filled, typed, sent, toaster) =
|
||||||
|
(draft.clone(), draft.clone(), draft.clone(), toaster.clone());
|
||||||
|
let starters = [
|
||||||
|
("Explain a lift", IconName::Sparkles),
|
||||||
|
("Review my palette", IconName::Palette),
|
||||||
|
("Draft release notes", IconName::FileText),
|
||||||
|
];
|
||||||
|
let theme = cx.theme();
|
||||||
|
section(
|
||||||
|
"WelcomeScreen / Greeting / SuggestionChips / StarterPrompts / CapabilityCards",
|
||||||
|
"The first thing a new conversation shows: a greeting for the time of day, the composer, prompts to start from and what the assistant can do; a press fills the composer.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(760.))
|
||||||
|
.h(px(520.))
|
||||||
|
.rounded(theme.radius(Radius::Lg))
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.colors.border)
|
||||||
|
.child(
|
||||||
|
WelcomeScreen::new("chat-welcome", hour)
|
||||||
|
.name("Ada")
|
||||||
|
.child(PromptInput::new("chat-welcome-compose", draft, move |_, cx| {
|
||||||
|
sent.update(cx, |input, cx| input.set_text("", cx));
|
||||||
|
toaster.update(cx, |toaster, cx| {
|
||||||
|
toaster.push(Toast::new("Message sent").severity(Severity::Success), cx);
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
.child(SuggestionChips::new("chat-starters", starters, move |ix, _, cx| {
|
||||||
|
let text = starters[ix].0;
|
||||||
|
filled.update(cx, |input, cx| input.set_text(text, cx))
|
||||||
|
}))
|
||||||
|
.child(CapabilityCards::new(
|
||||||
|
"chat-capabilities",
|
||||||
|
[
|
||||||
|
(IconName::Globe, "Search the web", "Answers with sources you can open."),
|
||||||
|
(IconName::Code, "Write and run code", "Tries an idea before you use it."),
|
||||||
|
(IconName::FileText, "Read your files", "Pages and notes you attach."),
|
||||||
|
],
|
||||||
|
move |ix, _, cx| {
|
||||||
|
let text = ["Search the web for ", "Write code that ", "Read my file and "][ix];
|
||||||
|
typed.update(cx, |input, cx| input.set_text(text, cx))
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn follow_ups_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let asked = keep("chat-follow-up", || None::<usize>, window, cx);
|
||||||
|
let now_asked = *asked.read(cx);
|
||||||
|
section(
|
||||||
|
"FollowUpSuggestions",
|
||||||
|
"Questions to ask next, under an answer; a press asks one.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(560.))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
MessageBubble::new(Role::Assistant)
|
||||||
|
.avatar(MessageAvatar::new("chat-followup-mark", Role::Assistant, "Assistant"))
|
||||||
|
.child(StreamingMarkdown::new("chat-followup-text", "Dark themes lift their accents further, because saturated color on charcoal looks louder.", false)),
|
||||||
|
)
|
||||||
|
.child(probe(
|
||||||
|
"chat-follow-ups",
|
||||||
|
div().w(px(560.)).child(FollowUpSuggestions::new(
|
||||||
|
"chat-follow-ups",
|
||||||
|
QUESTIONS,
|
||||||
|
move |ix, _, cx| set(&asked, Some(ix), cx),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.children(now_asked.map(|ix| MessageBubble::new(Role::User).child(QUESTIONS[ix]))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUESTIONS: [&str; 3] = [
|
||||||
|
"How much further, in numbers?",
|
||||||
|
"Does the same hold for text?",
|
||||||
|
"Show both themes side by side",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn prompts() -> Vec<Template> {
|
||||||
|
[
|
||||||
|
("Review this code", "🔍", "Code", "A careful read for bugs and clarity.", "Read the code below. List bugs first, then anything unclear, each with the line and a fix."),
|
||||||
|
("Explain like a textbook", "📘", "Writing", "Plain words, one idea at a time.", "Explain the idea below to a newcomer: define terms before using them, one idea per paragraph."),
|
||||||
|
("Release notes", "📝", "Writing", "What changed, for people who use it.", "Turn these changes into notes a user reads: what they can do now, what moved, what broke."),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, icon, category, description, markdown)| Template {
|
||||||
|
name: name.into(),
|
||||||
|
icon: icon.into(),
|
||||||
|
category: category.into(),
|
||||||
|
description: description.into(),
|
||||||
|
markdown: markdown.into(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn library_section(
|
||||||
|
draft: &Entity<TextInput>,
|
||||||
|
_: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> impl IntoElement + use<> {
|
||||||
|
let (all, used) = (prompts(), draft.clone());
|
||||||
|
let texts: Vec<_> = all.iter().map(|prompt| prompt.markdown.clone()).collect();
|
||||||
|
section(
|
||||||
|
"PromptLibrary",
|
||||||
|
"Saved prompts, found by name and kept by category, the one in view shown whole: documents::TemplatePicker over prompts.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().w(px(760.)).h(px(400.)).child(
|
||||||
|
TemplatePicker::new("chat-prompts", all)
|
||||||
|
.on_use(move |ix, _, cx| used.update(cx, |input, cx| input.set_text(texts[ix].to_string(), cx))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ mod stream;
|
|||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
mod tests;
|
mod tests;
|
||||||
mod voice;
|
mod voice;
|
||||||
|
mod welcome;
|
||||||
|
|
||||||
pub use actions::{
|
pub use actions::{
|
||||||
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
|
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
|
||||||
@@ -42,3 +43,4 @@ pub use status::{
|
|||||||
};
|
};
|
||||||
pub use stream::{StreamingCursor, StreamingMarkdown, StreamingText};
|
pub use stream::{StreamingCursor, StreamingMarkdown, StreamingText};
|
||||||
pub use voice::{VoiceInputButton, VoiceWaveform};
|
pub use voice::{VoiceInputButton, VoiceWaveform};
|
||||||
|
pub use welcome::{CapabilityCards, FollowUpSuggestions, SuggestionChips, WelcomeScreen};
|
||||||
@@ -10,6 +10,7 @@ use gpui::{
|
|||||||
mod cite;
|
mod cite;
|
||||||
mod compose;
|
mod compose;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod welcome;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock,
|
ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use std::{cell::Cell, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
Context, IntoElement, ParentElement, Pixels, Render, Styled, TestAppContext, Window, canvas,
|
||||||
|
div, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::settle;
|
||||||
|
use crate::{chat::WelcomeScreen, theme::Theme};
|
||||||
|
|
||||||
|
/// A welcome screen of `height` over a tall body, noting where its first child sits.
|
||||||
|
struct Welcome {
|
||||||
|
height: Pixels,
|
||||||
|
top: Rc<Cell<Pixels>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Welcome {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let top = self.top.clone();
|
||||||
|
div().w(px(600.0)).h(self.height).child(
|
||||||
|
WelcomeScreen::new("welcome", 9)
|
||||||
|
.child(
|
||||||
|
canvas(move |bounds, _, _| top.set(bounds.top()), |_, _, _, _| {}).h(px(10.0)),
|
||||||
|
)
|
||||||
|
.child(div().h(px(400.0))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_welcome_centers_what_fits_and_scrolls_what_does_not(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let top = Rc::new(Cell::new(Pixels::ZERO));
|
||||||
|
let seen = top.clone();
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Welcome {
|
||||||
|
height: px(1200.0),
|
||||||
|
top: seen,
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
assert!(
|
||||||
|
top.get() > px(300.0),
|
||||||
|
"centered in a tall space, not at {:?}",
|
||||||
|
top.get()
|
||||||
|
);
|
||||||
|
view.update(cx, |view, cx| {
|
||||||
|
view.height = px(200.0);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
assert!(
|
||||||
|
top.get() > Pixels::ZERO,
|
||||||
|
"a short space keeps the start in reach, not at {:?}",
|
||||||
|
top.get()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
|
||||||
|
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||||
|
transparent_black,
|
||||||
|
};
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
buttons::{Button, ButtonVariant},
|
||||||
|
forms::Pick,
|
||||||
|
primitives::{FocusRing, Icon, IconName},
|
||||||
|
theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A greeting for the hour of the day, 0 to 23.
|
||||||
|
pub(crate) fn greeting(hour: u8) -> &'static str {
|
||||||
|
match hour {
|
||||||
|
5..=11 => "Good morning",
|
||||||
|
12..=17 => "Good afternoon",
|
||||||
|
_ => "Good evening",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first thing a new conversation shows: a greeting by the time of day, then the owner's composer, starters and cards, in a centered column that scrolls when space runs short.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct WelcomeScreen {
|
||||||
|
id: ElementId,
|
||||||
|
hour: u8,
|
||||||
|
name: Option<SharedString>,
|
||||||
|
body: SmallVec<[AnyElement; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WelcomeScreen {
|
||||||
|
/// `hour` is the reader's, 0 to 23.
|
||||||
|
pub fn new(id: impl Into<ElementId>, hour: u8) -> Self {
|
||||||
|
assert!(hour < 24, "an hour of {hour}");
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
hour,
|
||||||
|
name: None,
|
||||||
|
body: SmallVec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reader's name, for the greeting.
|
||||||
|
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||||
|
self.name = Some(name.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParentElement for WelcomeScreen {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
|
self.body.extend(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for WelcomeScreen {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors.clone();
|
||||||
|
let words = match self.name {
|
||||||
|
Some(name) => format!("{}, {name}", greeting(self.hour)),
|
||||||
|
None => greeting(self.hour).to_string(),
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.id(self.id)
|
||||||
|
.size_full()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_center()
|
||||||
|
.p_6()
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.w_full()
|
||||||
|
.max_w(theme.container_width(ContainerSize::Md))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_6()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::Sparkles)
|
||||||
|
.size(IconSize::Lg)
|
||||||
|
.color(colors.accent),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.text_size(theme.text_size(TextSize::Xxl))
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(colors.fg)
|
||||||
|
.child(words),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.children(self.body),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompts to start from, as chips: each an icon and a few words; a press asks the owner to use it.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct SuggestionChips {
|
||||||
|
id: ElementId,
|
||||||
|
prompts: Vec<(SharedString, IconName)>,
|
||||||
|
on_pick: Pick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SuggestionChips {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
prompts: impl IntoIterator<Item = (impl Into<SharedString>, IconName)>,
|
||||||
|
on_pick: impl Fn(usize, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
prompts: prompts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(text, icon)| (text.into(), icon))
|
||||||
|
.collect(),
|
||||||
|
on_pick: Rc::new(on_pick),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for SuggestionChips {
|
||||||
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||||
|
div().flex().flex_wrap().justify_center().gap_2().children(
|
||||||
|
self.prompts
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, (text, icon))| {
|
||||||
|
let pick = self.on_pick.clone();
|
||||||
|
Button::new((self.id.clone(), format!("prompt-{ix}")), text)
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.icon(icon)
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
log::info!("starters: picked {ix}");
|
||||||
|
pick(ix, window, cx)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Questions to ask next, under an answer: each a quiet line; a press asks it.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct FollowUpSuggestions {
|
||||||
|
id: ElementId,
|
||||||
|
questions: Vec<SharedString>,
|
||||||
|
on_pick: Pick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FollowUpSuggestions {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
questions: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
on_pick: impl Fn(usize, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
questions: questions.into_iter().map(Into::into).collect(),
|
||||||
|
on_pick: Rc::new(on_pick),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for FollowUpSuggestions {
|
||||||
|
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()
|
||||||
|
.children(
|
||||||
|
self.questions
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, question)| {
|
||||||
|
let pick = self.on_pick.clone();
|
||||||
|
div()
|
||||||
|
.id((self.id.clone(), format!("question-{ix}")))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.px_2()
|
||||||
|
.py_1p5()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.border_1()
|
||||||
|
.border_color(transparent_black())
|
||||||
|
.tab_index(0)
|
||||||
|
.focus_ring(cx)
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|row| row.bg(colors.hover).text_color(colors.fg))
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, window, _| {
|
||||||
|
window.prevent_default()
|
||||||
|
})
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
log::info!("follow-ups: picked {ix}");
|
||||||
|
pick(ix, window, cx)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::CornerDownRight)
|
||||||
|
.size(IconSize::Sm)
|
||||||
|
.color(colors.fg_subtle),
|
||||||
|
)
|
||||||
|
.child(div().flex_1().min_w_0().child(question))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the assistant can do, as cards three across: an icon, a name and a line each; a press starts with that.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct CapabilityCards {
|
||||||
|
id: ElementId,
|
||||||
|
cards: Vec<(IconName, SharedString, SharedString)>,
|
||||||
|
on_pick: Pick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapabilityCards {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
cards: impl IntoIterator<Item = (IconName, impl Into<SharedString>, impl Into<SharedString>)>,
|
||||||
|
on_pick: impl Fn(usize, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
cards: cards
|
||||||
|
.into_iter()
|
||||||
|
.map(|(icon, title, line)| (icon, title.into(), line.into()))
|
||||||
|
.collect(),
|
||||||
|
on_pick: Rc::new(on_pick),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for CapabilityCards {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors.clone();
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(3)
|
||||||
|
.gap_3()
|
||||||
|
.children(
|
||||||
|
self.cards
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, (icon, title, line))| {
|
||||||
|
let pick = self.on_pick.clone();
|
||||||
|
div()
|
||||||
|
.id((self.id.clone(), format!("card-{ix}")))
|
||||||
|
.p_3()
|
||||||
|
.rounded(theme.radius(Radius::Lg))
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.tab_index(0)
|
||||||
|
.focus_ring(cx)
|
||||||
|
.bg(colors.surface)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|card| card.bg(colors.hover))
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, window, _| {
|
||||||
|
window.prevent_default()
|
||||||
|
})
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
log::info!("capabilities: picked {ix}");
|
||||||
|
pick(ix, window, cx)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mb_2()
|
||||||
|
.size(theme.avatar_size(AvatarSize::Sm))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.bg(colors.hover)
|
||||||
|
.child(
|
||||||
|
Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.child(title),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_0p5()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(line),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::greeting;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_greeting_follows_the_hour() {
|
||||||
|
assert_eq!(greeting(5), "Good morning");
|
||||||
|
assert_eq!(greeting(11), "Good morning");
|
||||||
|
assert_eq!(greeting(12), "Good afternoon");
|
||||||
|
assert_eq!(greeting(18), "Good evening");
|
||||||
|
assert_eq!(greeting(2), "Good evening");
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -156,11 +156,11 @@
|
|||||||
|
|
||||||
## T23e AI Chat · Welcome
|
## T23e AI Chat · Welcome
|
||||||
|
|
||||||
- [ ] WelcomeScreen / Greeting
|
- [x] WelcomeScreen / Greeting → `chat::WelcomeScreen`, a greeting for the hour
|
||||||
- [ ] SuggestionChips / StarterPrompts
|
- [x] SuggestionChips / StarterPrompts → `chat::SuggestionChips`
|
||||||
- [ ] PromptLibrary
|
- [x] PromptLibrary → `documents::TemplatePicker` over saved prompts
|
||||||
- [ ] FollowUpSuggestions
|
- [x] FollowUpSuggestions
|
||||||
- [ ] CapabilityCards
|
- [x] CapabilityCards
|
||||||
|
|
||||||
## T24 Agent — `src/agent`
|
## T24 Agent — `src/agent`
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user