T23a: AI chat, messages

- messages: yours in a quiet bubble, the assistant's as prose beside its mark,
  system notes, errors; avatars, headers with model and time, footers
- MessageList over gpui's bottom-aligned list: drawn near the view, held at the
  newest, counting arrivals while away; a way back down; day separators
- streaming text and markdown reveal toward the text as it grows, with a caret;
  a blinking cursor before the first words
- CodeBlock with language, copy, run, apply, download and folding;
  MarkdownRenderer::code lets messages draw fences with it
- pictures, grids, files, voice notes, video over host frames, link previews
- quotes, actions, an editor that takes focus, versions, regenerate, stop and
  continue
- errors with retry, a rate limit counting down, feedback, thinking blocks
- gallery: the AI Chat page with scripted states
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 05:35:09 -04:00
1 parent 03f6ceb583
commit c0afce2507
21 files changed
+3376 -34

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
relative,
};
use super::media::fitted;
use crate::{
buttons::{ButtonVariant, IconButton},
documents::source,
forms::OnFlag,
primitives::{Icon, IconName, Image},
theme::{ActiveTheme, ContainerSize, ControlSize, IconSize, Radius, TextSize},
typography::{DurationStyle, format::duration, tabular},
};
type OnSeek = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// A clip's time as it plays: what played and the length, in clock digits.
fn clock(played: Duration, length: Duration) -> String {
format!(
"{} / {}",
duration(played.as_secs(), DurationStyle::Clock),
duration(length.as_secs(), DurationStyle::Clock)
)
}
/// A voice note or a song: play and pause, a waveform lit as far as it has played, and the time; a press on the waveform seeks there. The host plays it.
#[derive(IntoElement)]
pub struct AudioMessage {
id: ElementId,
peaks: Rc<Vec<f32>>,
length: Duration,
played: Duration,
playing: bool,
on_toggle: Option<OnFlag>,
on_seek: Option<OnSeek>,
}
impl AudioMessage {
/// `peaks` are the waveform's heights, 0 to 1, left to right.
pub fn new(id: impl Into<ElementId>, peaks: impl Into<Vec<f32>>, length: Duration) -> Self {
let peaks: Vec<f32> = peaks.into();
assert!(!peaks.is_empty(), "a waveform has peaks");
assert!(
peaks.iter().all(|peak| (0.0..=1.0).contains(peak)),
"peaks lie within 0 to 1"
);
Self {
id: id.into(),
peaks: Rc::new(peaks),
length,
played: Duration::ZERO,
playing: false,
on_toggle: None,
on_seek: None,
}
}
/// How far it has played, and whether it plays now.
pub fn played(mut self, played: Duration, playing: bool) -> Self {
self.played = played.min(self.length);
self.playing = playing;
self
}
/// Gets true to play, false to pause.
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
/// Gets the share of the clip to seek to.
pub fn on_seek(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
self.on_seek = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AudioMessage {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let share = if self.length.is_zero() {
0.0
} else {
self.played.as_secs_f32() / self.length.as_secs_f32()
};
let count = self.peaks.len();
let playing = self.playing;
let tall = theme.control_height(ControlSize::Md);
div()
.w(theme.prose_width())
.flex()
.items_center()
.gap_3()
.px_2()
.py_1p5()
.rounded_full()
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.children(self.on_toggle.map(|toggle| {
IconButton::new(
(self.id.clone(), "toggle"),
if playing {
IconName::Pause
} else {
IconName::Play
},
)
.variant(ButtonVariant::Ghost)
.tooltip(if playing { "Pause" } else { "Play" })
.on_click(move |_, window, cx| {
log::info!("audio message: playing {}", !playing);
toggle(!playing, window, cx)
})
}))
.child(
div()
.flex_1()
.h(tall)
.flex()
.items_center()
.gap_px()
.children(self.peaks.iter().enumerate().map(|(ix, peak)| {
let seek = self.on_seek.clone();
let lit = (ix as f32 + 0.5) / count as f32 <= share;
div()
.id((self.id.clone(), format!("bar-{ix}")))
.flex_1()
.h_full()
.flex()
.items_center()
.when_some(seek, |bar, seek| {
bar.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
seek((ix as f32 + 0.5) / count as f32, window, cx)
})
})
.child(
div()
.w_full()
.h(tall * peak.max(0.08))
.rounded_full()
.bg(if lit { colors.accent } else { colors.fg_subtle }),
)
})),
)
.child(
tabular(
div()
.pr_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted),
)
.child(clock(self.played, self.length)),
)
}
}
/// A video in a message: the frame the host shows, its poster or the playing frame, with play and pause over it and the time. The host decodes and plays it.
#[derive(IntoElement)]
pub struct VideoMessage {
id: ElementId,
frame: SharedString,
size: (f32, f32),
length: Duration,
played: Duration,
playing: bool,
on_toggle: Option<OnFlag>,
}
impl VideoMessage {
/// `frame` is the picture to show, a file or a web address; `width` and `height` the video's pixels.
pub fn new(
id: impl Into<ElementId>,
frame: impl Into<SharedString>,
width: f32,
height: f32,
length: Duration,
) -> Self {
Self {
id: id.into(),
frame: frame.into(),
size: (width, height),
length,
played: Duration::ZERO,
playing: false,
on_toggle: None,
}
}
pub fn played(mut self, played: Duration, playing: bool) -> Self {
self.played = played.min(self.length);
self.playing = playing;
self
}
/// Gets true to play, false to pause.
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VideoMessage {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let widest = f32::from(
theme
.container_width(ContainerSize::Sm)
.to_pixels(window.rem_size()),
);
let (width, height) = fitted(self.size.0, self.size.1, widest);
let share = if self.length.is_zero() {
0.0
} else {
self.played.as_secs_f32() / self.length.as_secs_f32()
};
let playing = self.playing;
let toggle = self.on_toggle.clone();
div()
.id(self.id.clone())
.relative()
.w(Pixels::from(width))
.h(Pixels::from(height))
.bg(colors.media_backdrop)
.when_some(toggle, |video, toggle| {
video.cursor_pointer().on_click(move |_, window, cx| {
log::info!("video message: playing {}", !playing);
toggle(!playing, window, cx)
})
})
.child(
Image::new((self.id.clone(), "frame"), source(&self.frame))
.fit(ObjectFit::Contain)
.size_full(),
)
.when(!playing, |video| {
video.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.child(
div()
.size(theme.fab_size())
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(colors.media_backdrop.opacity(0.6))
.child(
Icon::new(IconName::Play)
.size(IconSize::Lg)
.color(colors.on_media),
),
),
)
})
.child(
div()
.absolute()
.left_0()
.right_0()
.bottom_0()
.flex()
.flex_col()
.child(
div().flex().justify_end().px_2().pb_1().child(
tabular(
div()
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.bg(colors.media_backdrop.opacity(0.6))
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.on_media),
)
.child(clock(self.played, self.length)),
),
)
.child(
div()
.h(theme.progress_thickness())
.w_full()
.bg(colors.on_media.opacity(0.25))
.child(div().h_full().w(relative(share)).bg(colors.on_media)),
),
)
}
}