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>
Showing 1 changed file with 97 additions and 36 deletions.
| @@ -1631,6 +1631,7 @@ impl ChatView { | ||
| 1631 | 1631 | } else { |
| 1632 | 1632 | let right_width = self.actions_width(cx); |
| 1633 | 1633 | v_flex() |
| 1634 | + .id("composer-card") | |
| 1634 | 1635 | .relative() |
| 1635 | 1636 | .w_full() |
| 1636 | 1637 | .p(px(1.)) |
| @@ -1638,6 +1639,19 @@ impl ChatView { | ||
| 1638 | 1639 | .bg(metal::vertical((p.s_hi, p.s))) |
| 1639 | 1640 | .shadow(card_shadow) |
| 1640 | 1641 | .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 | + })) | |
| 1641 | 1655 | .child(measure) |
| 1642 | 1656 | .child( |
| 1643 | 1657 | v_flex() |
| @@ -1683,55 +1697,93 @@ impl ChatView { | ||
| 1683 | 1697 | ) |
| 1684 | 1698 | } |
| 1685 | 1699 | |
| 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. | |
| 1686 | 1702 | fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 1687 | 1703 | 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() | |
| 1708 | 1738 | .well(p) |
| 1709 | - .text_size(px(12.5)) | |
| 1710 | - .text_color(p.ink2) | |
| 1739 | + .child(img(path.clone()).size_full().object_fit(ObjectFit::Cover)) | |
| 1711 | 1740 | .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 | + ), | |
| 1715 | 1749 | ) |
| 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)) | |
| 1717 | 1769 | .child( |
| 1718 | - MetalButton::new(("remove-attachment", ix)) | |
| 1770 | + MetalButton::new(("remove-file", ix)) | |
| 1719 | 1771 | .ghost() |
| 1720 | 1772 | .mini() |
| 1773 | + .size(px(24.)) | |
| 1721 | 1774 | .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)), | |
| 1729 | 1777 | ) |
| 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))) | |
| 1731 | 1784 | } |
| 1732 | 1785 | } |
| 1733 | 1786 | |
| 1734 | - | |
| 1735 | 1787 | impl Render for ChatView { |
| 1736 | 1788 | fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 1737 | 1789 | let is_empty = self.session.read(cx).items.is_empty(); |
| @@ -2495,3 +2547,12 @@ fn format_elapsed(seconds: u64) -> String { | ||
| 2495 | 2547 | _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60), |
| 2496 | 2548 | } |
| 2497 | 2549 | } |
| 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 | + } | |