Vectrum
Sign In

Drag threads between shelves, and mark unsent text on cards

Dragging a thread onto a pinned card pins it, onto an active card unpins,
wakes or un-settles it, and onto the Settled shelf settles it, as in T3
Code. Threads with something typed but not sent show the draft pen and a
warm tint, with Discard draft among the hover actions.

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

Showing 2 changed files with 149 additions and 3 deletions.

+6−0src/ui/chat.rsView File
@@ -230,6 +230,12 @@ impl ChatView {
230230 (self.composer.read(cx).value().to_string(), self.attachments.len())
231231 }
232232
233+ /// Empties the composer, as discarding an unsent draft does.
234+ pub fn clear_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
235+ self.attachments.clear();
236+ self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
237+ }
238+
233239 /// Sends what's in the composer. While the agent works, the follow-up
234240 /// setting decides between steering it now and queueing for after the
235241 /// turn; `opposite` flips that for this one message.
+143−3src/ui/workspace/sidebar.rsView File
@@ -146,6 +146,14 @@ impl Workspace {
146146 (!text.trim().is_empty() || attachments > 0).then_some((text, attachments))
147147 }
148148
149+ /// Whether a thread's composer holds something typed but not sent.
150+ pub(super) fn has_unsent(&self, session: &Entity<Session>, cx: &App) -> bool {
151+ self.chats.get(&session.entity_id()).is_some_and(|chat| {
152+ let (text, attachments) = chat.read(cx).draft(cx);
153+ !text.trim().is_empty() || attachments > 0
154+ })
155+ }
156+
149157 /// The inbox's threads in their sections, within the project filter.
150158 pub(super) fn inbox(&self, cx: &App) -> Inbox {
151159 let working_shelf = settings::get(|settings| settings.working_section);
@@ -932,6 +940,17 @@ impl Workspace {
932940 .font_weight(FontWeight::MEDIUM)
933941 .text_color(ink)
934942 .hover(|style| style.bg(p.row_hover))
943+ .when(id == "settled-shelf", |this| {
944+ let workspace = cx.weak_entity();
945+ let hover = p.menu_row_hover;
946+ this.drag_over::<DraggedThread>(move |style, dragged, _, cx| {
947+ if dragged.session.read(cx).settled { style } else { style.bg(hover) }
948+ })
949+ .on_drop(move |dragged: &DraggedThread, window, cx| {
950+ let session = dragged.session.clone();
951+ let _ = workspace.update(cx, |this, cx| this.drop_thread(session, DropZone::Settled, window, cx));
952+ })
953+ })
935954 .on_click(cx.listener(move |this, _, _, cx| {
936955 toggle(this);
937956 if !this.settled_open {
@@ -1217,8 +1236,22 @@ impl Workspace {
12171236 cx.stop_propagation();
12181237 this.press_settle(settle_session.clone(), window, cx);
12191238 }));
1220- let draft = self.draft_of(session, cx).is_some();
1221- let hover_actions = h_flex().gap(px(1.)).children(snooze_button).child(settle_button);
1239+ let draft = !active && self.has_unsent(session, cx);
1240+ let discard_session = session.clone();
1241+ let discard_button = draft.then(|| {
1242+ MetalButton::new(("discard-unsent", key))
1243+ .ghost()
1244+ .mini()
1245+ .icon(Icon::new(Lucide::X).size(px(14.)))
1246+ .tooltip("Discard draft")
1247+ .on_click(cx.listener(move |this, _, window, cx| {
1248+ cx.stop_propagation();
1249+ if let Some(chat) = this.chats.get(&discard_session.entity_id()).cloned() {
1250+ chat.update(cx, |chat, cx| chat.clear_draft(window, cx));
1251+ }
1252+ }))
1253+ });
1254+ let hover_actions = h_flex().gap(px(1.)).children(discard_button).children(snooze_button).child(settle_button);
12221255 let cover = if active { metal::vertical(p.row_on) } else { p.page.into() };
12231256 let corner = h_flex().ml_auto().flex_none().min_w(px(32.)).justify_end().child(if armed {
12241257 hover_actions.into_any_element()
@@ -1327,6 +1360,7 @@ impl Workspace {
13271360 }
13281361 })
13291362 .when(working_dim, |this| this.opacity(0.7).hover(|style| style.opacity(1.)))
1363+ .map(|this| self.drag_and_drop(this, session, if pinned { DropZone::Pinned } else { DropZone::Active }, cx))
13301364 .tooltip(self.hover_card(project_ix, session, cx))
13311365 .on_click({
13321366 let session = session.clone();
@@ -1378,7 +1412,7 @@ impl Workspace {
13781412 let snoozed_until = state.snoozed_until;
13791413 let settled_at = state.settled_at.unwrap_or(state.updated);
13801414 let last_message = state.last_message.max(state.created);
1381- let draft = self.draft_of(session, cx).is_some();
1415+ let draft = !active && self.has_unsent(session, cx);
13821416 let time: AnyElement = match kind {
13831417 SlimKind::Snoozed => div()
13841418 .text_size(px(12.))
@@ -1450,6 +1484,15 @@ impl Workspace {
14501484 this.hover(|style| style.bg(p.row_hover))
14511485 }
14521486 })
1487+ .map(|this| {
1488+ let zone = match kind {
1489+ SlimKind::Settled => DropZone::Settled,
1490+ SlimKind::Snoozed => DropZone::None,
1491+ SlimKind::Card if pinned => DropZone::Pinned,
1492+ SlimKind::Card => DropZone::Active,
1493+ };
1494+ self.drag_and_drop(this, session, zone, cx)
1495+ })
14531496 .tooltip(self.hover_card(project_ix, session, cx))
14541497 .on_click({
14551498 let session = session.clone();
@@ -1734,6 +1777,103 @@ pub(super) fn hover_swap(
17341777 )
17351778 }
17361779
1780+ /// A thread row being dragged to another shelf.
1781+ #[derive(Clone)]
1782+ pub(super) struct DraggedThread {
1783+ session: Entity<Session>,
1784+ title: SharedString,
1785+ }
1786+
1787+ impl Render for DraggedThread {
1788+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1789+ let p = metal::palette(cx);
1790+ div()
1791+ .max_w(px(240.))
1792+ .px(px(12.))
1793+ .py(px(6.))
1794+ .rounded(px(12.))
1795+ .raised(p)
1796+ .truncate()
1797+ .text_size(px(13.))
1798+ .font_weight(FontWeight::MEDIUM)
1799+ .text_color(p.ink)
1800+ .child(self.title.clone())
1801+ }
1802+ }
1803+
1804+ /// Where a dragged thread lands, and so what dropping it does: pin, unpin
1805+ /// (or wake, or un-settle) among the active cards, or settle.
1806+ #[derive(Clone, Copy, PartialEq, Eq)]
1807+ pub(super) enum DropZone {
1808+ Pinned,
1809+ Active,
1810+ Settled,
1811+ None,
1812+ }
1813+
1814+ impl DropZone {
1815+ /// Whether dropping this thread here changes anything.
1816+ fn accepts(self, session: &Session) -> bool {
1817+ match self {
1818+ DropZone::Pinned => !session.pinned || session.settled || session.is_snoozed(),
1819+ DropZone::Active => session.pinned || session.settled || session.is_snoozed(),
1820+ DropZone::Settled => !session.settled,
1821+ DropZone::None => false,
1822+ }
1823+ }
1824+ }
1825+
1826+ impl Workspace {
1827+ /// Makes a row draggable, and a place to drop other rows.
1828+ fn drag_and_drop(&self, row: Stateful<Div>, session: &Entity<Session>, zone: DropZone, cx: &mut Context<Self>) -> Stateful<Div> {
1829+ let p = metal::palette(cx);
1830+ let dragged = DraggedThread { session: session.clone(), title: session.read(cx).title.clone() };
1831+ let row = row.on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone()));
1832+ if zone == DropZone::None {
1833+ return row;
1834+ }
1835+ let workspace = cx.weak_entity();
1836+ let ring = p.green_ink.opacity(0.7);
1837+ let own = session.entity_id();
1838+ row.drag_over::<DraggedThread>(move |style, dragged, _, cx| {
1839+ if dragged.session.entity_id() != own && zone.accepts(dragged.session.read(cx)) {
1840+ style.border_1().border_color(ring)
1841+ } else {
1842+ style
1843+ }
1844+ })
1845+ .on_drop(move |dragged: &DraggedThread, window, cx| {
1846+ let session = dragged.session.clone();
1847+ let _ = workspace.update(cx, |this, cx| this.drop_thread(session, zone, window, cx));
1848+ })
1849+ }
1850+
1851+ /// Applies a drop: pins, unpins, wakes, un-settles or settles.
1852+ fn drop_thread(&mut self, session: Entity<Session>, zone: DropZone, window: &mut Window, cx: &mut Context<Self>) {
1853+ let (pinned, settled, snoozed) = {
1854+ let state = session.read(cx);
1855+ (state.pinned, state.settled, state.is_snoozed())
1856+ };
1857+ match zone {
1858+ DropZone::Pinned | DropZone::Active => {
1859+ if settled {
1860+ self.unsettle_thread(session.clone(), cx);
1861+ }
1862+ if snoozed {
1863+ self.wake_thread(session.clone(), cx);
1864+ }
1865+ if zone == DropZone::Pinned && !pinned {
1866+ session.update(cx, |session, cx| session.set_pinned(true, cx));
1867+ } else if zone == DropZone::Active && pinned {
1868+ self.unpin_thread(session, window, cx);
1869+ }
1870+ }
1871+ DropZone::Settled if !settled => self.settle_threads(vec![session], window, cx),
1872+ _ => {}
1873+ }
1874+ }
1875+ }
1876+
17371877 /// Which slim row: a snoozed or settled thread, or a card in compact mode.
17381878 #[derive(Clone, Copy, PartialEq, Eq)]
17391879 pub(super) enum SlimKind {