//! T3 Code's thread actions, shared by the sidebar and the chat header: the
//! thread action menu, the multi-select and draft menus, snooze presets and
//! the custom snooze dialog, confirmations with T3's wording, copying, and
//! the "Settled 1 thread, Ctrl+Z to undo" notice.
use chrono::{Datelike as _, Local, NaiveDate, NaiveTime, TimeZone as _};
use gpui_kit::component::{
Icon, IconName, WindowExt as _,
button::ButtonVariant,
h_flex,
input::{Input, InputState},
menu::{DropdownMenu as _, PopupMenu, PopupMenuItem},
notification::Notification,
calendar::{Calendar, CalendarEvent, CalendarState, Matcher},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::{ProjectKey, Workspace};
use crate::clock;
use crate::session::Session;
use crate::settings;
use crate::ui::chat::ChatView;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalButton, MetalStyled as _};
const HOUR: Duration = Duration::from_secs(3_600);
const DAY: Duration = Duration::from_secs(86_400);
/// How long the undo notice stays after the latest action.
const UNDO_WINDOW: Duration = Duration::from_secs(5);
use std::time::{Duration, SystemTime};
// ---- snoozing --------------------------------------------------------------
/// One of the snooze menu's presets: `In 1 hour (3:42 PM)`.
#[derive(Clone)]
pub(super) struct SnoozePreset {
pub label: &'static str,
/// The wake time as the menu shows it: `3:42 PM`, `Mon 9:00 AM`.
pub when: String,
pub until: SystemTime,
}
/// T3's snooze presets as of now: in 1 and 3 hours, this evening (while
/// it's more than an hour off), tomorrow morning, and next Monday morning
/// (unless that's tomorrow).
pub(super) fn snooze_presets() -> Vec<SnoozePreset> {
let now = SystemTime::now();
let in_an_hour = now + HOUR;
let in_three_hours = now + 3 * HOUR;
let mut presets = vec![
SnoozePreset { label: "In 1 hour", when: clock::clock(in_an_hour), until: in_an_hour },
SnoozePreset { label: "In 3 hours", when: clock::clock(in_three_hours), until: in_three_hours },
];
let evening = clock::at_hour(0, 18);
if evening.duration_since(now).is_ok_and(|left| left > HOUR) {
presets.push(SnoozePreset { label: "This evening", when: clock::clock(evening), until: evening });
}
let tomorrow = clock::at_hour(1, 9);
presets.push(SnoozePreset { label: "Tomorrow", when: clock::clock(tomorrow), until: tomorrow });
let weekday = Local::now().weekday().num_days_from_sunday() as i64;
let days_until_monday = match (1 - weekday + 7) % 7 {
0 => 7,
days => days,
};
let next_week = clock::at_hour(days_until_monday, 9);
if next_week != tomorrow {
let day: chrono::DateTime<Local> = next_week.into();
presets.push(SnoozePreset {
label: "Next week",
when: format!("{} {}", day.format("%a"), clock::clock(next_week)),
until: next_week,
});
}
presets
}
/// The countdown a snoozed row shows: `45m`, `2h`, `3d`, or `now` once due.
pub(super) fn wake_countdown(until: SystemTime) -> String {
let Ok(left) = until.duration_since(SystemTime::now()) else { return "now".into() };
let ms = left.as_millis();
if left.is_zero() {
"now".into()
} else if left < HOUR {
format!("{}m", ms.div_ceil(60_000).max(1))
} else if left < DAY {
format!("{}h", ms.div_ceil(HOUR.as_millis()))
} else {
format!("{}d", ms.div_ceil(DAY.as_millis()))
}
}
/// The long relative time T3 uses in the command palette and the legacy
/// sidebar: `just now`, `5m ago`, `3h ago`, `2d ago`.
pub(super) fn relative_time_long(time: SystemTime) -> String {
match super::relative_time(time).as_str() {
"now" => "just now".into(),
short => format!("{short} ago"),
}
}
// ---- confirmations and toasts ----------------------------------------------
/// Splits a confirmation message as T3's dialog does: the first line ending
/// in "?" is the title and the other lines the description.
pub(super) fn confirm_copy(message: &str) -> (String, Option<String>) {
let message = message.trim();
let lines: Vec<&str> = message.lines().collect();
if let Some(ix) = lines.iter().position(|line| line.trim().ends_with('?')) {
let rest: Vec<&str> = lines.iter().enumerate().filter(|(i, _)| *i != ix).map(|(_, line)| *line).collect();
let description = rest.join("\n").trim().to_string();
return (lines[ix].trim().to_string(), Some(description).filter(|text| !text.is_empty()));
}
if let Some(ix) = message.find('?') {
let description = message[ix + 1..].trim().to_string();
return (message[..=ix].trim().to_string(), Some(description).filter(|text| !text.is_empty()));
}
let description = if message.is_empty() { "This action requires your confirmation." } else { message };
("Confirm action".into(), Some(description.into()))
}
/// Asks before an action, with T3's Cancel and Confirm buttons; Confirm is
/// red when the action destroys something.
pub(super) fn confirm(
message: &str,
destructive: bool,
window: &mut Window,
cx: &mut App,
on_confirm: impl Fn(&mut Window, &mut App) + 'static,
) {
let (title, description) = confirm_copy(message);
let on_confirm = std::rc::Rc::new(on_confirm);
window.open_alert_dialog(cx, move |alert, _, _| {
let on_confirm = on_confirm.clone();
alert
.title(title.clone())
.when_some(description.clone(), |alert, description| alert.description(description))
.ok_text("Confirm")
.cancel_text("Cancel")
.when(destructive, |alert| alert.ok_variant(ButtonVariant::Danger))
.show_cancel(true)
.on_ok(move |_, window, cx| {
on_confirm(window, cx);
true
})
});
}
/// Copies `text` and says so in a toast titled `title` ("Path copied").
pub(super) fn copy(text: String, title: &'static str, window: &mut Window, cx: &mut App) {
cx.write_to_clipboard(ClipboardItem::new_string(text.clone()));
window.push_notification(Notification::success(text).title(title), cx);
}
fn error_toast(title: &'static str, description: &str, window: &mut Window, cx: &mut App) {
window.push_notification(Notification::error(description.to_string()).title(title), cx);
}
// ---- undo ----------------------------------------------------------------
/// What the undo notice says was done.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum UndoKind {
Settled,
Snoozed,
Unpinned,
Archived,
Discarded,
}
impl UndoKind {
fn label(self) -> &'static str {
match self {
UndoKind::Settled => "Settled",
UndoKind::Snoozed => "Snoozed",
UndoKind::Unpinned => "Unpinned",
UndoKind::Archived => "Archived",
UndoKind::Discarded => "Discarded",
}
}
}
/// One action the notice can take back.
pub(super) struct UndoEntry {
pub kind: UndoKind,
pub session: Entity<Session>,
/// For a discarded draft: where it was and its conversation view, kept
/// alive until the notice expires.
pub draft: Option<(ProjectKey, Entity<ChatView>)>,
}
impl Workspace {
/// Records an action for the undo notice, which stays 5 seconds after
/// the latest one.
pub(super) fn push_undo(&mut self, entry: UndoEntry, cx: &mut Context<Self>) {
self.undo.push(entry);
self.undo_generation += 1;
let generation = self.undo_generation;
self.undo_timer = cx.spawn(async move |this, cx| {
cx.background_executor().timer(UNDO_WINDOW).await;
let _ = this.update(cx, |this, cx| {
if this.undo_generation == generation {
this.expire_undo(cx);
}
});
});
cx.notify();
}
/// Lets go of what can no longer be undone: discarded drafts end for good.
fn expire_undo(&mut self, cx: &mut Context<Self>) {
for entry in std::mem::take(&mut self.undo) {
if entry.draft.is_some() {
entry.session.update(cx, |session, _| session.shutdown());
}
}
cx.notify();
}
/// The notice's text: the latest action and how many like it came right
/// before, as in "Settled 2 threads".
pub(super) fn undo_notice(&self) -> Option<String> {
let latest = self.undo.last()?.kind;
let count = self.undo.iter().rev().take_while(|entry| entry.kind == latest).count();
let noun = if latest == UndoKind::Discarded { "draft" } else { "thread" };
Some(format!("{} {count} {noun}{}", latest.label(), if count == 1 { "" } else { "s" }))
}
/// Takes back the latest group of like actions; false when there's none.
pub(super) fn undo_latest(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(latest) = self.undo.last().map(|entry| entry.kind) else { return false };
let keep = self.undo.iter().rposition(|entry| entry.kind != latest).map_or(0, |ix| ix + 1);
let group = self.undo.split_off(keep);
for entry in group {
// Taking an action back keeps the thread where it was in the list.
let updated = entry.session.read(cx).updated;
match entry.kind {
UndoKind::Settled => entry.session.update(cx, |session, cx| session.unsettle(cx)),
UndoKind::Snoozed => entry.session.update(cx, |session, cx| session.snooze(None, cx)),
UndoKind::Unpinned => entry.session.update(cx, |session, cx| session.set_pinned(true, cx)),
UndoKind::Archived => entry.session.update(cx, |session, cx| session.set_archived(false, cx)),
UndoKind::Discarded => {
if let Some((key, chat)) = entry.draft {
self.reattach_thread(&key, entry.session.clone(), chat, window, cx);
}
}
}
entry.session.update(cx, |session, _| session.updated = updated);
}
cx.notify();
true
}
// ---- what a thread is and where ---------------------------------------
/// The branch a thread is on: its worktree's, or its project checkout's.
pub(super) fn thread_branch(&self, session: &Entity<Session>, cx: &App) -> Option<String> {
let state = session.read(cx);
if let Some(worktree) = &state.worktree {
return Some(worktree.branch.clone());
}
let project = self.projects.get(self.project_of(session)?)?;
project.git.as_ref().and_then(|git| git.branch.clone())
}
/// The folder a thread works in: its worktree, or its project.
pub(super) fn thread_path(&self, session: &Entity<Session>, cx: &App) -> Option<std::path::PathBuf> {
let state = session.read(cx);
if let Some(worktree) = &state.worktree {
return Some(worktree.root.clone());
}
self.projects.get(self.project_of(session)?).map(|project| project.path.clone())
}
/// Whether a thread can be snoozed now: not while it waits on the user.
pub(super) fn can_snooze(session: &Session) -> bool {
!session.needs_attention() && !session.preparing
}
// ---- lifecycle ---------------------------------------------------------
/// Settles threads with an undo notice, moving off the open one.
pub(super) fn settle_threads(&mut self, sessions: Vec<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
self.settle_armed = None;
for session in sessions {
if session.read(cx).settled {
continue;
}
self.leave_thread(&session, window, cx);
session.update(cx, |session, cx| session.settle(cx));
self.push_undo(UndoEntry { kind: UndoKind::Settled, session, draft: None }, cx);
}
cx.notify();
}
pub(super) fn unsettle_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
session.update(cx, |session, cx| session.unsettle(cx));
cx.notify();
}
/// Settles the thread, or un-settles it when it's settled (Ctrl+Shift+S).
pub(super) fn toggle_settled(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
if session.read(cx).settled {
self.unsettle_thread(session, cx);
} else {
self.settle_threads(vec![session], window, cx);
}
}
pub(super) fn snooze_threads(
&mut self,
sessions: Vec<Entity<Session>>,
until: SystemTime,
window: &mut Window,
cx: &mut Context<Self>,
) {
for session in sessions {
if !Self::can_snooze(session.read(cx)) {
continue;
}
self.leave_thread(&session, window, cx);
session.update(cx, |session, cx| session.snooze(Some(until), cx));
self.push_undo(UndoEntry { kind: UndoKind::Snoozed, session, draft: None }, cx);
}
cx.notify();
}
pub(super) fn wake_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
session.update(cx, |session, cx| session.snooze(None, cx));
cx.notify();
}
/// Pins the thread, or unpins it (asking first when Unpin confirmation is on).
pub(super) fn toggle_pin(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
if session.read(cx).pinned {
self.unpin_thread(session, window, cx);
} else {
session.update(cx, |session, cx| session.set_pinned(true, cx));
}
}
pub(super) fn unpin_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let unpin = {
let workspace = cx.weak_entity();
let session = session.clone();
move |_: &mut Window, cx: &mut App| {
session.update(cx, |session, cx| session.set_pinned(false, cx));
let session = session.clone();
let _ = workspace.update(cx, |this, cx| {
this.push_undo(UndoEntry { kind: UndoKind::Unpinned, session, draft: None }, cx)
});
}
};
if settings::get(|settings| settings.confirm_unpin) {
let title = session.read(cx).title.clone();
confirm(
&format!("Unpin thread \"{title}\"?\nThis will move the thread out of your pinned section."),
false,
window,
cx,
unpin,
);
} else {
unpin(window, cx);
}
}
/// Archives a thread (asking first when Archive confirmation is on): it
/// leaves the sidebar and waits under Settings → Archive.
pub(super) fn archive_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let archive = {
let workspace = cx.weak_entity();
let session = session.clone();
move |window: &mut Window, cx: &mut App| {
let session = session.clone();
let _ = workspace.update(cx, |this, cx| {
this.leave_thread(&session, window, cx);
session.update(cx, |session, cx| {
session.settle(cx);
session.set_archived(true, cx);
});
this.push_undo(UndoEntry { kind: UndoKind::Archived, session, draft: None }, cx);
});
}
};
if settings::get(|settings| settings.confirm_archive) {
let title = session.read(cx).title.clone();
confirm(&format!("Archive thread \"{title}\"?"), false, window, cx, archive);
} else {
archive(window, cx);
}
}
/// Deletes threads for good, asking first when Delete confirmation is on.
pub(super) fn delete_threads(&mut self, sessions: Vec<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
let Some(first) = sessions.first() else { return };
let message = if sessions.len() == 1 {
format!(
"Delete thread \"{}\"?\nThis permanently clears conversation history for this thread.",
first.read(cx).title
)
} else {
format!(
"Delete {} threads?\nThis permanently clears conversation history for these threads.",
sessions.len()
)
};
let delete = {
let workspace = cx.weak_entity();
move |window: &mut Window, cx: &mut App| {
let sessions = sessions.clone();
let _ = workspace.update(cx, |this, cx| {
for session in sessions {
this.forget_thread(session, window, cx);
}
this.selection.clear();
});
}
};
if settings::get(|settings| settings.confirm_delete) {
confirm(&message, true, window, cx, delete);
} else {
delete(window, cx);
}
}
/// Moves off a thread that's leaving the inbox: to the next active
/// card, or a new thread in the same project when none is left.
pub(super) fn leave_thread(&mut self, session: &Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
if self.active.as_ref() != Some(session) {
return;
}
let cards = self.inbox_cards(cx);
let ix = cards.iter().position(|(_, candidate)| candidate == session);
let next = match ix {
Some(ix) => cards.get(ix + 1).or_else(|| ix.checked_sub(1).and_then(|ix| cards.get(ix))),
None => cards.first(),
}
.cloned();
match next {
Some((project, next)) => self.select(project, next, window, cx),
None => {
let project = self.project_of(session).unwrap_or(self.active_project);
self.new_thread(project, window, cx);
}
}
}
// ---- drafts -----------------------------------------------------------
/// Discards an unsent draft with an undo notice.
pub(super) fn discard_draft(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let Some(project_ix) = self.project_of(&session) else { return };
let was_active = self.active.as_ref() == Some(&session);
let key = self.project_key(project_ix);
let Some(chat) = self.chats.get(&session.entity_id()).cloned() else { return };
self.detach_thread(project_ix, &session, cx);
if was_active {
self.active = None;
self.new_thread(project_ix, window, cx);
}
self.push_undo(UndoEntry { kind: UndoKind::Discarded, session, draft: Some((key, chat)) }, cx);
}
// ---- other thread actions -----------------------------------------------
pub(super) fn mark_unread(&mut self, sessions: Vec<Entity<Session>>, cx: &mut Context<Self>) {
for session in sessions {
session.update(cx, |session, cx| session.set_unread(true, cx));
}
self.selection.clear();
cx.notify();
}
pub(super) fn regenerate_titles(&mut self, sessions: Vec<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
let off = |session: &Entity<Session>| settings::get_for(&session.read(cx).project, |settings| settings.text_model.is_empty());
if sessions.iter().all(off) {
error_toast(
if sessions.len() == 1 { "Failed to regenerate thread title" } else { "Failed to regenerate thread titles" },
"Pick a text generation model under Settings → General first.",
window,
cx,
);
return;
}
for session in sessions {
session.update(cx, |session, cx| session.regenerate_title(cx));
}
self.selection.clear();
}
/// Starts a thread on the same branch: in the thread's worktree when it
/// has one, otherwise on the project checkout.
pub(super) fn new_thread_on_branch(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let Some(project_ix) = self.project_of(&session) else { return };
let (worktree, cwd) = {
let state = session.read(cx);
(state.worktree.clone(), state.cwd.clone())
};
let settings = self.new_thread_settings(project_ix, cx);
if self.projects[project_ix].environment.is_some() {
self.new_thread_with(project_ix, settings, window, cx);
return;
}
let new = self.spawn_thread(project_ix, settings, None, window, cx);
if let Some(worktree) = worktree {
new.update(cx, |new, cx| new.adopt_worktree(worktree, cwd, cx));
}
self.select(project_ix, new, window, cx);
}
/// Scopes the sidebar to the thread's project, or back to all projects
/// when it's already scoped there.
pub(super) fn toggle_project_filter(&mut self, project_ix: usize, cx: &mut Context<Self>) {
let key = self.project_key(project_ix);
let scope = if self.project_scope.as_ref() == Some(&key) { None } else { Some(key) };
self.set_project_scope(scope, cx);
}
pub(super) fn set_project_scope(&mut self, scope: Option<ProjectKey>, cx: &mut Context<Self>) {
self.project_scope = scope;
self.selection.clear();
cx.notify();
}
/// Opens the project's settings. They live in the settings view.
pub(super) fn open_project_settings(&mut self, _project_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
self.open_settings_page("General", window, cx);
}
pub(super) fn copy_thread_id(session: &Entity<Session>, window: &mut Window, cx: &mut App) {
copy(session.read(cx).id.clone(), "Thread ID copied", window, cx);
}
/// Copies the thread's pull request link, or its id when it has none
/// (Ctrl+Shift+C).
pub(super) fn copy_thread_reference(session: &Entity<Session>, window: &mut Window, cx: &mut App) {
match session.read(cx).pull_request.as_ref().map(|pull_request| pull_request.url.clone()) {
Some(url) => copy(url, "PR link copied", window, cx),
None => Self::copy_thread_id(session, window, cx),
}
}
// ---- the menus -----------------------------------------------------------
/// The thread action menu, item for item as T3 builds it. `sidebar`
/// adds "Filter by {project}", which the chat header leaves out.
pub(super) fn thread_action_menu(
workspace: WeakEntity<Self>,
session: Entity<Session>,
sidebar: bool,
mut menu: PopupMenu,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let Some(this) = workspace.upgrade() else { return menu };
let (branch, project_ix, filter_label, filter_active, path) = {
let this = this.read(cx);
let project_ix = this.project_of(&session);
let project = project_ix.and_then(|ix| this.projects.get(ix));
(
this.thread_branch(&session, cx),
project_ix,
project.map(|project| project.name.clone()),
project_ix.is_some_and(|ix| this.project_scope.as_ref() == Some(&this.project_key(ix))),
this.thread_path(&session, cx),
)
};
let state = session.read(cx);
let (pinned, settled, snoozed, can_snooze, regenerating, running, auto_settle, has_messages) = (
state.pinned,
state.settled,
state.is_snoozed(),
Self::can_snooze(state),
state.regenerating_title,
state.is_working(),
state.auto_settle,
state.first_user_text().is_some(),
);
let act = |run: fn(&mut Workspace, Entity<Session>, &mut Window, &mut Context<Workspace>)| {
let workspace = workspace.clone();
let session = session.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx));
}
};
let icon = |icon: Lucide| Icon::new(icon);
menu = menu.min_w(px(208.)).max_w(px(384.));
if let Some(branch) = &branch {
menu = menu.item(
PopupMenuItem::new(format!("New thread on {branch}"))
.icon(icon(Lucide::MessageSquarePlus))
.on_click(act(|this, session, window, cx| this.new_thread_on_branch(session, window, cx))),
);
}
menu = menu.item(if pinned {
PopupMenuItem::new("Unpin thread")
.icon(icon(Lucide::PinOff))
.on_click(act(|this, session, window, cx| this.unpin_thread(session, window, cx)))
} else {
PopupMenuItem::new("Pin thread")
.icon(icon(Lucide::Pin))
.on_click(act(|_, session, _, cx| session.update(cx, |session, cx| session.set_pinned(true, cx))))
});
menu = menu.item(if settled {
PopupMenuItem::new("Un-settle thread")
.icon(Icon::new(IconName::CircleCheck))
.on_click(act(|this, session, _, cx| this.unsettle_thread(session, cx)))
} else {
PopupMenuItem::new("Settle thread")
.icon(Icon::new(IconName::CircleCheck))
.on_click(act(|this, session, window, cx| this.settle_threads(vec![session], window, cx)))
});
if snoozed {
menu = menu.item(
PopupMenuItem::new("Wake thread")
.icon(icon(Lucide::Clock))
.on_click(act(|this, session, _, cx| this.wake_thread(session, cx))),
);
} else {
menu = Self::snooze_submenu(menu, workspace.clone(), vec![session.clone()], "Snooze".into(), !can_snooze, true, window, cx);
}
menu = menu.separator().item(PopupMenuItem::new("Rename thread").icon(icon(Lucide::Pencil)).on_click(if sidebar {
act(|this, session, window, cx| this.start_rename(session, true, window, cx))
} else {
act(|this, session, window, cx| this.start_rename(session, false, window, cx))
}));
if has_messages {
menu = menu.item(
PopupMenuItem::new(if regenerating { "Regenerating…" } else { "Regenerate title" })
.icon(Icon::new(IconName::RefreshCw))
.disabled(regenerating)
.on_click(act(|this, session, window, cx| this.regenerate_titles(vec![session], window, cx))),
);
}
menu = menu.item(
PopupMenuItem::new("Mark unread")
.icon(icon(Lucide::MailOpen))
.on_click(act(|this, session, _, cx| this.mark_unread(vec![session], cx))),
);
if sidebar && let (Some(project_ix), Some(label)) = (project_ix, filter_label) {
let workspace = workspace.clone();
menu = menu.item(
PopupMenuItem::new(if filter_active { "Show all projects".to_string() } else { format!("Filter by {label}") })
.icon(icon(Lucide::FolderTree))
.on_click(move |_, _, cx| {
let _ = workspace.update(cx, |this, cx| this.toggle_project_filter(project_ix, cx));
}),
);
}
{
let session = session.clone();
menu = menu.submenu_with_icon(Some(icon(Lucide::Timer)), "Auto-settle behavior", window, cx, move |menu, _, _| {
let set = |on: bool| {
let session = session.clone();
move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
session.update(cx, |session, cx| session.set_auto_settle(on, cx));
}
};
menu.item(PopupMenuItem::new("Enabled").checked(auto_settle).on_click(set(true)))
.item(PopupMenuItem::new("Disabled").checked(!auto_settle).on_click(set(false)))
});
}
{
let (branch, id) = (branch.clone(), session.read(cx).id.clone());
menu = menu.separator().submenu_with_icon(Some(Icon::new(IconName::Copy)), "Copy", window, cx, move |mut menu, _, _| {
let path = path.clone();
menu = menu.item(PopupMenuItem::new("Path").icon(Icon::new(IconName::Folder)).on_click(move |_, window, cx| {
match path.clone() {
Some(path) => copy(path.display().to_string(), "Path copied", window, cx),
None => error_toast("Path unavailable", "This thread does not have a workspace path to copy.", window, cx),
}
}));
if let Some(branch) = branch.clone() {
menu = menu.item(
PopupMenuItem::new("Branch")
.icon(Icon::new(Lucide::GitBranch))
.on_click(move |_, window, cx| copy(branch.clone(), "Branch copied", window, cx)),
);
}
let id = id.clone();
menu.item(
PopupMenuItem::new("Thread ID")
.icon(Icon::new(Lucide::Hash))
.on_click(move |_, window, cx| copy(id.clone(), "Thread ID copied", window, cx)),
)
});
}
if let Some(project_ix) = project_ix {
let workspace = workspace.clone();
menu = menu.item(PopupMenuItem::new("Project settings").icon(Icon::new(IconName::Settings)).on_click(
move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.open_project_settings(project_ix, window, cx));
},
));
}
menu.separator()
.item(
PopupMenuItem::new("Archive thread")
.icon(icon(Lucide::Archive))
.disabled(running)
.on_click(act(|this, session, window, cx| this.archive_thread(session, window, cx))),
)
.item(destructive_item("Delete", Lucide::Trash, cx).on_click(act(|this, session, window, cx| {
this.delete_threads(vec![session], window, cx)
})))
}
/// The Snooze submenu: T3's presets with their wake times, then Custom….
#[allow(clippy::too_many_arguments)]
fn snooze_submenu(
menu: PopupMenu,
workspace: WeakEntity<Self>,
sessions: Vec<Entity<Session>>,
label: SharedString,
disabled: bool,
with_icon: bool,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let build = move |mut menu: PopupMenu, _: &mut Window, _: &mut Context<PopupMenu>| {
for preset in snooze_presets() {
let workspace = workspace.clone();
let sessions = sessions.clone();
let until = preset.until;
menu = menu.item(PopupMenuItem::new(format!("{} ({})", preset.label, preset.when)).on_click(
move |_, window, cx| {
let sessions = sessions.clone();
let _ = workspace.update(cx, |this, cx| this.snooze_threads(sessions, until, window, cx));
},
));
}
let workspace = workspace.clone();
let sessions = sessions.clone();
menu.separator().item(PopupMenuItem::new("Custom…").on_click(move |_, window, cx| {
let sessions = sessions.clone();
let _ = workspace.update(cx, |this, cx| this.request_custom_snooze(sessions, window, cx));
}))
};
let icon = with_icon.then(|| Icon::new(Lucide::Clock));
if disabled {
let submenu = PopupMenu::build(window, cx, build);
menu.item(PopupMenuItem::submenu(label, submenu).when_some(icon, |item, icon| item.icon(icon)).disabled(true))
} else {
menu.submenu_with_icon(icon, label, window, cx, build)
}
}
/// The menu for several selected rows. Counts only cover rows that show.
pub(super) fn multi_select_menu(
workspace: WeakEntity<Self>,
sessions: Vec<Entity<Session>>,
mut menu: PopupMenu,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let count = sessions.len();
let pinned: Vec<_> = sessions.iter().filter(|session| session.read(cx).pinned).cloned().collect();
let settleable: Vec<_> = sessions.iter().filter(|session| !session.read(cx).settled).cloned().collect();
let all_snoozable = sessions.iter().all(|session| Self::can_snooze(session.read(cx)));
let regenerating = sessions.iter().any(|session| session.read(cx).regenerating_title);
let titled: Vec<_> =
sessions.iter().filter(|session| session.read(cx).first_user_text().is_some()).cloned().collect();
let run = |sessions: Vec<Entity<Session>>,
action: fn(&mut Workspace, Vec<Entity<Session>>, &mut Window, &mut Context<Workspace>)| {
let workspace = workspace.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
let sessions = sessions.clone();
let _ = workspace.update(cx, |this, cx| action(this, sessions, window, cx));
}
};
menu = menu.min_w(px(160.));
if !pinned.is_empty() {
menu = menu.item(PopupMenuItem::new(format!("Unpin ({})", pinned.len())).on_click(run(
pinned,
|this, sessions, _, cx| {
for session in sessions {
session.update(cx, |session, cx| session.set_pinned(false, cx));
this.push_undo(UndoEntry { kind: UndoKind::Unpinned, session, draft: None }, cx);
}
this.selection.clear();
},
)));
}
menu = menu.item(
PopupMenuItem::new(format!("Settle ({count})"))
.disabled(settleable.is_empty())
.on_click(run(settleable, |this, sessions, window, cx| {
this.settle_threads(sessions, window, cx);
this.selection.clear();
})),
);
if all_snoozable {
menu = Self::snooze_submenu(menu, workspace.clone(), sessions.clone(), format!("Snooze ({count})").into(), false, false, window, cx);
}
menu = menu.item(if regenerating {
PopupMenuItem::new(format!("Regenerating… ({count})")).disabled(true)
} else {
PopupMenuItem::new(format!("Regenerate titles ({})", titled.len()))
.disabled(titled.is_empty())
.on_click(run(titled, |this, sessions, window, cx| this.regenerate_titles(sessions, window, cx)))
});
menu.item(
PopupMenuItem::new(format!("Mark unread ({count})"))
.on_click(run(sessions.clone(), |this, sessions, _, cx| this.mark_unread(sessions, cx))),
)
.item(
destructive_item(format!("Delete ({count})"), None::<Lucide>, cx)
.on_click(run(sessions, |this, sessions, window, cx| this.delete_threads(sessions, window, cx))),
)
}
/// The menu of an unsent draft row.
pub(super) fn draft_menu(
workspace: WeakEntity<Self>,
session: Entity<Session>,
mut menu: PopupMenu,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let Some(this) = workspace.upgrade() else { return menu };
let (path, branch, project_ix) = {
let this = this.read(cx);
let project_ix = this.project_of(&session);
let scratch = project_ix
.and_then(|ix| this.projects.get(ix))
.is_some_and(|project| Some(&project.path) == super::scratch_dir().as_ref());
(this.thread_path(&session, cx), this.thread_branch(&session, cx), project_ix.filter(|_| !scratch))
};
let has_copy = path.is_some() || branch.is_some();
let copy_menu = {
let (path, branch) = (path.clone(), branch.clone());
move |mut menu: PopupMenu, _: &mut Window, _: &mut Context<PopupMenu>| {
if let Some(path) = path.clone() {
menu = menu.item(PopupMenuItem::new("Path").icon(Icon::new(IconName::Folder)).on_click(
move |_, window, cx| copy(path.display().to_string(), "Path copied", window, cx),
));
}
if let Some(branch) = branch.clone() {
menu = menu.item(PopupMenuItem::new("Branch").icon(Icon::new(Lucide::GitBranch)).on_click(
move |_, window, cx| copy(branch.clone(), "Branch copied", window, cx),
));
}
menu
}
};
menu = menu.min_w(px(160.));
menu = if has_copy {
menu.submenu_with_icon(Some(Icon::new(IconName::Copy)), "Copy", window, cx, copy_menu)
} else {
let submenu = PopupMenu::build(window, cx, copy_menu);
menu.item(PopupMenuItem::submenu("Copy", submenu).icon(Icon::new(IconName::Copy)).disabled(true))
};
if let Some(project_ix) = project_ix {
let workspace = workspace.clone();
menu = menu.item(PopupMenuItem::new("Project settings").icon(Icon::new(IconName::Settings)).on_click(
move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.open_project_settings(project_ix, window, cx));
},
));
}
menu.separator().item(destructive_item("Discard draft", Lucide::Trash, cx).on_click(move |_, window, cx| {
let session = session.clone();
let _ = workspace.update(cx, |this, cx| this.discard_draft(session, window, cx));
}))
}
// ---- custom snooze ---------------------------------------------------
/// Opens T3's Custom snooze dialog for these threads.
pub(super) fn request_custom_snooze(&mut self, sessions: Vec<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
let workspace = cx.weak_entity();
let form = cx.new(|cx| CustomSnooze::new(window, cx));
let submit = {
let form = form.clone();
move |window: &mut Window, cx: &mut App| {
let Some(until) = form.update(cx, |form, cx| form.resolve(cx)) else { return };
window.close_dialog(cx);
let sessions = sessions.clone();
let _ = workspace.update(cx, |this, cx| this.snooze_threads(sessions, until, window, cx));
}
};
let submit = std::rc::Rc::new(submit);
window.open_dialog(cx, move |dialog, _, cx| {
let p = metal::palette(cx);
let submit = submit.clone();
dialog
.w(px(384.))
// The calendar pops out past the dialog; a click there isn't a click away.
.overlay_closable(false)
.title(
v_flex()
.gap(px(4.))
.child(div().text_size(px(16.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child("Custom snooze"))
.child(
div()
.text_size(px(13.))
.font_weight(FontWeight::NORMAL)
.text_color(p.ink3)
.child("Choose when snoozed threads return to your inbox."),
),
)
.child(form.clone())
.footer(
h_flex()
.w_full()
.justify_end()
.gap(px(8.))
.child(
MetalButton::new("custom-snooze-cancel")
.compact()
.label("Cancel")
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
MetalButton::new("custom-snooze-submit")
.primary()
.compact()
.label("Snooze")
.on_click(move |_, window, cx| submit(window, cx)),
),
)
});
}
}
/// A red menu row for actions that destroy something.
pub(super) fn destructive_item(label: impl Into<SharedString>, icon: impl Into<Option<Lucide>>, cx: &App) -> PopupMenuItem {
let red = metal::palette(cx).red;
let label = label.into();
let item = PopupMenuItem::element(move |_, _| div().text_color(red).child(label.clone()));
match icon.into() {
Some(icon) => item.icon(Icon::new(icon).text_color(red)),
None => item,
}
}
/// Date and time, or a duration.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SnoozeMode {
Date,
Duration,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SnoozeUnit {
Minutes,
Hours,
Days,
}
impl SnoozeUnit {
fn label(self) -> &'static str {
match self {
SnoozeUnit::Minutes => "Minutes",
SnoozeUnit::Hours => "Hours",
SnoozeUnit::Days => "Days",
}
}
fn duration(self) -> Duration {
match self {
SnoozeUnit::Minutes => Duration::from_secs(60),
SnoozeUnit::Hours => HOUR,
SnoozeUnit::Days => DAY,
}
}
}
/// The body of the Custom snooze dialog.
struct CustomSnooze {
mode: SnoozeMode,
date: NaiveDate,
/// The date's calendar, past days disabled, and whether it's open.
calendar: Entity<CalendarState>,
calendar_open: bool,
_calendar_events: Subscription,
time: Entity<InputState>,
amount: Entity<InputState>,
unit: SnoozeUnit,
error: Option<&'static str>,
}
impl CustomSnooze {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let initial: chrono::DateTime<Local> = (SystemTime::now() + HOUR).into();
let time = cx.new(|cx| InputState::new(window, cx).placeholder("HH:MM").default_value(initial.format("%H:%M").to_string()));
let amount = cx.new(|cx| InputState::new(window, cx).default_value("2"));
let today = Local::now().date_naive();
let calendar = cx.new(|cx| {
let mut calendar = CalendarState::new(window, cx).disabled_matcher(Matcher::interval(Some(today), None));
calendar.set_date(initial.date_naive(), window, cx);
calendar
});
let calendar_events = cx.subscribe(&calendar, |this, _, event: &CalendarEvent, cx| {
let CalendarEvent::Selected(date) = event;
if let Some(date) = date.start() {
this.date = date;
this.calendar_open = false;
this.error = None;
cx.notify();
}
});
Self {
mode: SnoozeMode::Date,
date: initial.date_naive(),
calendar,
calendar_open: false,
_calendar_events: calendar_events,
time,
amount,
unit: SnoozeUnit::Hours,
error: None,
}
}
/// The wake time the form describes, or `None` (showing why) when it
/// isn't valid and in the future.
fn resolve(&mut self, cx: &mut Context<Self>) -> Option<SystemTime> {
let now = SystemTime::now();
let wake = match self.mode {
SnoozeMode::Duration => self
.amount
.read(cx)
.value()
.trim()
.parse::<f64>()
.ok()
.filter(|amount| amount.is_finite() && *amount > 0.)
.map(|amount| now + self.unit.duration().mul_f64(amount)),
SnoozeMode::Date => NaiveTime::parse_from_str(self.time.read(cx).value().trim(), "%H:%M")
.ok()
.and_then(|time| Local.from_local_datetime(&self.date.and_time(time)).single())
.map(SystemTime::from),
}
.filter(|wake| *wake > now);
if wake.is_none() {
self.error = Some(match self.mode {
SnoozeMode::Date => "Choose a valid date and time in the future.",
SnoozeMode::Duration => "Enter a positive duration.",
});
cx.notify();
}
wake
}
fn step_amount(&mut self, delta: f64, window: &mut Window, cx: &mut Context<Self>) {
let amount = self.amount.read(cx).value().trim().parse::<f64>().unwrap_or(0.);
let next = (amount + delta).max(0.);
let text = if next.fract() == 0. { format!("{next:.0}") } else { next.to_string() };
self.amount.update(cx, |input, cx| input.set_value(text, window, cx));
self.error = None;
cx.notify();
}
}
impl Render for CustomSnooze {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let entity = cx.entity().downgrade();
let label = |text: &'static str| div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(text);
let field = |child: AnyElement| {
h_flex().h(px(34.)).px(px(10.)).gap(px(6.)).rounded(px(12.)).well(p).text_size(px(13.)).child(child)
};
let mode_switch = {
let entity = entity.clone();
metal::segmented(
"snooze-mode",
&[(SnoozeMode::Date, "Date and time"), (SnoozeMode::Duration, "Duration")],
self.mode,
p,
move |mode, _, cx| {
let _ = entity.update(cx, |this, cx| {
this.mode = mode;
this.error = None;
cx.notify();
});
},
)
};
let fields = match self.mode {
SnoozeMode::Date => {
let date = self.date;
let toggle = entity.clone();
let close = entity.clone();
let date_button = div()
.relative()
.child(
MetalButton::new("snooze-date")
.compact()
.w_full()
.label(date.format("%b %-d, %Y").to_string())
.stretch()
.icon(IconName::Calendar)
.on_click(move |_, _, cx| {
let _ = toggle.update(cx, |this, cx| {
this.calendar_open = !this.calendar_open;
cx.notify();
});
}),
)
.when(self.calendar_open, |this| {
this.child(
div().absolute().top_full().left_0().child(
deferred(
anchored().snap_to_window_with_margin(px(8.)).child(
crate::ui::pickers::panel(p)
.mt(px(4.))
.p(px(8.))
.on_mouse_down_out(move |_, _, cx| {
let _ = close.update(cx, |this, cx| {
this.calendar_open = false;
cx.notify();
});
})
.child(Calendar::new(&self.calendar)),
),
)
.with_priority(100),
),
)
});
h_flex()
.items_start()
.gap(px(12.))
.child(v_flex().flex_1().min_w_0().gap(px(6.)).child(label("Date")).child(date_button))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(6.))
.child(label("Time"))
.child(field(Input::new(&self.time).appearance(false).into_any_element())),
)
.into_any_element()
}
SnoozeMode::Duration => {
let unit = self.unit;
let entity_for_unit = entity.clone();
let dec = entity.clone();
let inc = entity.clone();
h_flex()
.items_start()
.gap(px(12.))
.child(
v_flex().flex_1().min_w_0().gap(px(6.)).child(label("Snooze for")).child(
h_flex()
.gap(px(4.))
.child(
MetalButton::new("snooze-decrease")
.ghost()
.mini()
.icon(IconName::Minus)
.tooltip("Decrease duration")
.on_click(move |_, window, cx| {
let _ = dec.update(cx, |this, cx| this.step_amount(-1., window, cx));
}),
)
.child(div().flex_1().child(field(Input::new(&self.amount).appearance(false).into_any_element())))
.child(
MetalButton::new("snooze-increase")
.ghost()
.mini()
.icon(IconName::Plus)
.tooltip("Increase duration")
.on_click(move |_, window, cx| {
let _ = inc.update(cx, |this, cx| this.step_amount(1., window, cx));
}),
),
),
)
.child(
v_flex().flex_1().min_w_0().gap(px(6.)).child(label("Unit")).child(
MetalButton::new("snooze-unit")
.compact()
.w_full()
.label(unit.label())
.stretch()
.caret()
.dropdown_menu(move |mut menu, _, _| {
for option in [SnoozeUnit::Minutes, SnoozeUnit::Hours, SnoozeUnit::Days] {
let entity = entity_for_unit.clone();
menu = menu.item(PopupMenuItem::new(option.label()).checked(option == unit).on_click(
move |_, _, cx| {
let _ = entity.update(cx, |this, cx| {
this.unit = option;
this.error = None;
cx.notify();
});
},
));
}
menu
}),
),
)
.into_any_element()
}
};
v_flex()
.gap(px(16.))
.child(h_flex().justify_center().child(mode_switch))
.child(fields)
.when_some(self.error, |this, error| this.child(div().text_size(px(13.)).text_color(p.red).child(error)))
}
}
#[cfg(test)]
mod tests {
use super::{confirm_copy, wake_countdown};
use std::time::{Duration, SystemTime};
#[::core::prelude::v1::test]
fn confirm_messages_split_like_t3() {
let (title, body) = confirm_copy("Delete thread \"x\"?\nThis permanently clears conversation history for this thread.");
assert_eq!(title, "Delete thread \"x\"?");
assert_eq!(body.as_deref(), Some("This permanently clears conversation history for this thread."));
assert_eq!(confirm_copy("Archive thread \"y\"?").1, None);
assert_eq!(confirm_copy("Do it").0, "Confirm action");
}
#[::core::prelude::v1::test]
fn snooze_countdowns() {
let now = SystemTime::now();
assert_eq!(wake_countdown(now - Duration::from_secs(5)), "now");
assert_eq!(wake_countdown(now + Duration::from_secs(45 * 60 - 5)), "45m");
assert_eq!(wake_countdown(now + Duration::from_secs(90 * 60)), "2h");
assert_eq!(wake_countdown(now + Duration::from_secs(50 * 3_600)), "3d");
}
}