Vectrum
Sign In

Attachments like T3 Code's, and files dropped on the composer

Images attached to a message show as thumbnails with a remove button, other
files as a list with their size. Files dropped on the composer are attached;
it lights up while they're over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 1473d25commite57384b

Showing 1 changed file with 97 additions and 36 deletions.

+97−36src/ui/chat.rsView File
@@ -1631,6 +1631,7 @@ impl ChatView {
16311631 } else {
16321632 let right_width = self.actions_width(cx);
16331633 v_flex()
1634+ .id("composer-card")
16341635 .relative()
16351636 .w_full()
16361637 .p(px(1.))
@@ -1638,6 +1639,19 @@ impl ChatView {
16381639 .bg(metal::vertical((p.s_hi, p.s)))
16391640 .shadow(card_shadow)
16401641 .when(approval, |this| this.opacity(0.92))
1642+ // Files dropped on the composer are attached.
1643+ .drag_over::<ExternalPaths>(move |style, _, _, _| {
1644+ style.bg(p.menu_row_hover).border_1().border_color(p.green_deep.opacity(0.7))
1645+ })
1646+ .on_drop(cx.listener(|this, paths: &ExternalPaths, window, cx| {
1647+ for path in paths.paths() {
1648+ if !this.attachments.contains(path) {
1649+ this.attachments.push(path.clone());
1650+ }
1651+ }
1652+ this.focus_composer(window, cx);
1653+ cx.notify();
1654+ }))
16411655 .child(measure)
16421656 .child(
16431657 v_flex()
@@ -1683,55 +1697,93 @@ impl ChatView {
16831697 )
16841698 }
16851699
1700+ /// What's attached to the message, as T3 Code shows it above the
1701+ /// prompt: images as thumbnails, other files as a list with their size.
16861702 fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement {
16871703 let p = metal::palette(cx);
1688- h_flex()
1689- .flex_wrap()
1690- .gap(px(6.))
1691- .px(px(12.))
1692- .pt(px(12.))
1693- .children(self.attachments.iter().enumerate().map(|(ix, path)| {
1694- let name = path
1695- .file_name()
1696- .map(|name| name.to_string_lossy().to_string())
1697- .unwrap_or_else(|| path.display().to_string());
1698- let is_image = matches!(
1699- path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(),
1700- Some("png" | "jpg" | "jpeg" | "gif" | "webp")
1701- );
1702- h_flex()
1703- .h(px(28.))
1704- .pl(px(10.))
1705- .pr(px(3.))
1706- .gap(px(7.))
1707- .rounded(px(10.))
1704+ let is_image = |path: &PathBuf| {
1705+ matches!(
1706+ path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(),
1707+ Some("png" | "jpg" | "jpeg" | "gif" | "webp")
1708+ )
1709+ };
1710+ let chat = cx.entity().downgrade();
1711+ let remove = |ix: usize, _: &mut Context<Self>| {
1712+ let chat = chat.clone();
1713+ move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
1714+ let _ = chat.update(cx, |this, cx| {
1715+ if ix < this.attachments.len() {
1716+ this.attachments.remove(ix);
1717+ }
1718+ cx.notify();
1719+ });
1720+ }
1721+ };
1722+ let name_of = |path: &PathBuf| {
1723+ path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| path.display().to_string())
1724+ };
1725+ let images: Vec<AnyElement> = self
1726+ .attachments
1727+ .iter()
1728+ .enumerate()
1729+ .filter(|(_, path)| is_image(path))
1730+ .map(|(ix, path)| {
1731+ let name = name_of(path);
1732+ div()
1733+ .id(("attachment-image", ix))
1734+ .relative()
1735+ .size(px(64.))
1736+ .rounded(px(8.))
1737+ .overflow_hidden()
17081738 .well(p)
1709- .text_size(px(12.5))
1710- .text_color(p.ink2)
1739+ .child(img(path.clone()).size_full().object_fit(ObjectFit::Cover))
17111740 .child(
1712- Icon::new(if is_image { Lucide::Image } else { Lucide::File })
1713- .size(px(13.))
1714- .text_color(p.ink3),
1741+ div().absolute().top(px(4.)).right(px(4.)).child(
1742+ MetalButton::new(("remove-image", ix))
1743+ .mini()
1744+ .size(px(20.))
1745+ .icon(Icon::new(IconName::Close).size(px(11.)))
1746+ .tooltip(format!("Remove {name}"))
1747+ .on_click(remove(ix, cx)),
1748+ ),
17151749 )
1716- .child(div().max_w(px(220.)).truncate().child(name))
1750+ .into_any_element()
1751+ })
1752+ .collect();
1753+ let files: Vec<AnyElement> = self
1754+ .attachments
1755+ .iter()
1756+ .enumerate()
1757+ .filter(|(_, path)| !is_image(path))
1758+ .map(|(ix, path)| {
1759+ let name = name_of(path);
1760+ let size = std::fs::metadata(path).map(|meta| format_size(meta.len())).unwrap_or_default();
1761+ h_flex()
1762+ .id(("attachment-file", ix))
1763+ .gap(px(8.))
1764+ .py(px(4.))
1765+ .text_size(px(14.))
1766+ .child(Icon::new(Lucide::File).size(px(16.)).text_color(p.ink3))
1767+ .child(div().flex_1().min_w_0().truncate().text_color(p.ink).child(name.clone()))
1768+ .child(div().text_size(px(12.)).text_color(p.ink3).child(size))
17171769 .child(
1718- MetalButton::new(("remove-attachment", ix))
1770+ MetalButton::new(("remove-file", ix))
17191771 .ghost()
17201772 .mini()
1773+ .size(px(24.))
17211774 .icon(IconName::Close)
1722- .tooltip("Remove")
1723- .on_click(cx.listener(move |this, _, _, cx| {
1724- if ix < this.attachments.len() {
1725- this.attachments.remove(ix);
1726- }
1727- cx.notify();
1728- })),
1775+ .tooltip(format!("Remove {name}"))
1776+ .on_click(remove(ix, cx)),
17291777 )
1730- }))
1778+ .into_any_element()
1779+ })
1780+ .collect();
1781+ v_flex()
1782+ .when(!images.is_empty(), |this| this.child(h_flex().flex_wrap().gap(px(8.)).mb(px(12.)).children(images)))
1783+ .when(!files.is_empty(), |this| this.child(v_flex().gap(px(4.)).mb(px(12.)).children(files)))
17311784 }
17321785 }
17331786
1734-
17351787 impl Render for ChatView {
17361788 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
17371789 let is_empty = self.session.read(cx).items.is_empty();
@@ -2495,3 +2547,12 @@ fn format_elapsed(seconds: u64) -> String {
24952547 _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
24962548 }
24972549 }
2550+
2551+ /// `12 KB`, `3.4 MB`.
2552+ fn format_size(bytes: u64) -> String {
2553+ match bytes {
2554+ 0..1_024 => format!("{bytes} B"),
2555+ 1_024..1_048_576 => format!("{:.0} KB", bytes as f64 / 1_024.),
2556+ _ => format!("{:.1} MB", bytes as f64 / 1_048_576.),
2557+ }
2558+ }