Vectrum
Sign In

Rebuild the sidebar, thread menus and command palette after T3 Code

The sidebar is now T3 Code's inbox: search with a project filter, unsent
drafts, pinned and active cards with their status (Working, Approval,
Input, Limited, Failed, Woke, Done) and Snooze and Settle on hover, then
the Working, Snoozed and Settled shelves, an undo notice, and Settings and
Usage at the foot. Right-clicking a thread, or clicking its title in the
chat header, opens T3's thread action menu: pin, settle, snooze presets
and a custom snooze, rename, regenerate title, mark unread, filter by
project, auto-settle, copy, archive and delete, with T3's confirmations.
Ctrl/Shift-click picks several threads for the multi-select menu, and a
double-click renames in place. The grouped layout lives on as T3's legacy
sidebar, with its sort menu and project menu (rename, grouping, remove).

Ctrl+K opens a command palette with T3's actions, recent threads, and
projects, settings and threads when searching, and the app takes T3's
default keymap: Ctrl+Shift+[ and ] step through threads, Ctrl+1-9 jump
(with hints while Ctrl is held), Ctrl+Shift+S settles, Ctrl+Shift+P pins,
Ctrl+Z undoes, Ctrl+U opens usage, Ctrl+Alt+B the right panel.

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

Showing 15 changed files with 5754 additions and 832 deletions.

+10−0src/git.rsView File
@@ -22,6 +22,16 @@ fn run(command: &mut Command) -> Result<String, String> {
2222 }
2323 }
2424
25+ /// The repository a folder is in, named by its common directory (shared by
26+ /// all of its worktrees), and the folder's path inside the checkout.
27+ pub fn repository_of(path: &Path) -> Option<(PathBuf, PathBuf)> {
28+ let common = git(path, &["rev-parse", "--path-format=absolute", "--git-common-dir"]).ok()?;
29+ let top = git(path, &["rev-parse", "--show-toplevel"]).ok()?;
30+ let top = PathBuf::from(top.trim());
31+ let inside = path.strip_prefix(&top).map(Path::to_path_buf).unwrap_or_default();
32+ Some((PathBuf::from(common.trim()), inside))
33+ }
34+
2535 /// What the project panel shows about a project's git state.
2636 #[derive(Clone, Debug, Default, PartialEq)]
2737 pub struct GitInfo {
+53−3src/main.rsView File
@@ -41,7 +41,27 @@ gpui_kit::actions!(
4141 FocusSearch,
4242 OpenSettings,
4343 CloseSettings,
44- Quit
44+ Quit,
45+ // T3 Code's commands, by its default keymap.
46+ ToggleCommandPalette,
47+ NewThreadLocal,
48+ NewThreadWithoutProject,
49+ PreviousThread,
50+ NextThread,
51+ SettleThread,
52+ PinThread,
53+ UndoThreadAction,
54+ CopyThreadReference,
55+ OpenUsage,
56+ ToggleRightPanel,
57+ SelectTheme,
58+ CycleAppearance,
59+ // Keys for lists with a text field: the palette, sidebar search, the
60+ // project filter, and inline renames.
61+ SelectPrevious,
62+ SelectNext,
63+ Dismiss,
64+ PaletteBack
4565 ]
4666 );
4767
@@ -64,16 +84,46 @@ fn main() {
6484 ui::browser::BrowserWatch::init(cx);
6585
6686 cx.bind_keys([
87+ // T3 Code's default keymap, for the commands the app has.
88+ KeyBinding::new("ctrl-b", ToggleSidebar, None),
89+ KeyBinding::new("ctrl-k", ToggleCommandPalette, None),
6790 KeyBinding::new("ctrl-n", NewThread, None),
91+ KeyBinding::new("ctrl-shift-o", NewThread, None),
92+ KeyBinding::new("ctrl-shift-n", NewThreadLocal, None),
93+ KeyBinding::new("ctrl-alt-n", NewThreadWithoutProject, None),
94+ KeyBinding::new("ctrl-shift-[", PreviousThread, None),
95+ KeyBinding::new("ctrl-shift-]", NextThread, None),
96+ KeyBinding::new("ctrl-{", PreviousThread, None),
97+ KeyBinding::new("ctrl-}", NextThread, None),
98+ KeyBinding::new("ctrl-shift-s", SettleThread, None),
99+ KeyBinding::new("ctrl-shift-p", PinThread, None),
100+ KeyBinding::new("ctrl-z", UndoThreadAction, Some("Workspace")),
101+ KeyBinding::new("ctrl-z", UndoThreadAction, Some("Workspace > Input")),
102+ KeyBinding::new("ctrl-shift-c", CopyThreadReference, None),
103+ KeyBinding::new("ctrl-u", OpenUsage, None),
104+ KeyBinding::new("ctrl-alt-b", ToggleRightPanel, None),
105+ KeyBinding::new("ctrl-alt-a", SelectTheme, None),
106+ KeyBinding::new("ctrl-alt-shift-a", CycleAppearance, None),
107+ // The app's own.
68108 KeyBinding::new("ctrl-o", OpenProject, None),
69- KeyBinding::new("ctrl-b", ToggleSidebar, None),
70109 KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None),
71110 KeyBinding::new("ctrl-shift-e", ToggleBrowser, None),
72- KeyBinding::new("ctrl-k", FocusSearch, None),
73111 KeyBinding::new("ctrl-,", OpenSettings, None),
74112 KeyBinding::new("ctrl-q", Quit, None),
75113 KeyBinding::new("escape", Interrupt, Some("ChatView")),
76114 KeyBinding::new("escape", CloseSettings, Some("SettingsView")),
115+ // Lists with a text field.
116+ KeyBinding::new("up", SelectPrevious, Some("CommandPalette > Input")),
117+ KeyBinding::new("down", SelectNext, Some("CommandPalette > Input")),
118+ KeyBinding::new("escape", Dismiss, Some("CommandPalette > Input")),
119+ KeyBinding::new("backspace", PaletteBack, Some("CommandPalette > Input")),
120+ KeyBinding::new("up", SelectPrevious, Some("SidebarSearch > Input")),
121+ KeyBinding::new("down", SelectNext, Some("SidebarSearch > Input")),
122+ KeyBinding::new("escape", Dismiss, Some("SidebarSearch > Input")),
123+ KeyBinding::new("up", SelectPrevious, Some("ScopePicker > Input")),
124+ KeyBinding::new("down", SelectNext, Some("ScopePicker > Input")),
125+ KeyBinding::new("escape", Dismiss, Some("ScopePicker > Input")),
126+ KeyBinding::new("escape", Dismiss, Some("InlineRename > Input")),
77127 ]);
78128 cx.on_action(quit);
79129 cx.on_window_closed(|cx, _| cx.quit()).detach();
+163−0src/session.rsView File
@@ -452,6 +452,19 @@ pub struct Session {
452452 pub pinned: bool,
453453 /// Hidden from the thread list until then.
454454 pub snoozed_until: Option<SystemTime>,
455+ /// When the thread was settled, for the Settled shelf's "settled 2h ago".
456+ pub settled_at: Option<SystemTime>,
457+ /// Something happened the user hasn't seen: a turn finished while the
458+ /// thread wasn't open, or they marked it unread.
459+ pub unread: bool,
460+ /// Woke from a snooze and the user hasn't looked yet.
461+ pub woke: bool,
462+ /// Whether the app may settle the thread on its own (Auto-settle behavior).
463+ pub auto_settle: bool,
464+ /// Taken out of the sidebar; kept under Settings → Archive.
465+ pub archived: bool,
466+ /// A title is being regenerated.
467+ pub regenerating_title: bool,
455468 /// When a usage limit that stopped the thread resets.
456469 pub limit_resets: Option<SystemTime>,
457470 /// When to continue the thread on its own, after a usage limit.
@@ -564,6 +577,12 @@ impl Session {
564577 settled: false,
565578 pinned: false,
566579 snoozed_until: None,
580+ settled_at: None,
581+ unread: false,
582+ woke: false,
583+ auto_settle: true,
584+ archived: false,
585+ regenerating_title: false,
567586 limit_resets: None,
568587 resume_at: None,
569588 queue: Vec::new(),
@@ -772,7 +791,9 @@ impl Session {
772791 self.last_message = now;
773792 self.updated = now;
774793 self.settled = false;
794+ self.settled_at = None;
775795 self.snoozed_until = None;
796+ self.woke = false;
776797 self.resume_at = None;
777798 self.interrupted = false;
778799 if first {
@@ -1027,6 +1048,84 @@ impl Session {
10271048 .detach();
10281049 }
10291050
1051+ /// Writes a new title from the first message with the text generation
1052+ /// model, replacing a custom one.
1053+ pub fn regenerate_title(&mut self, cx: &mut Context<Self>) {
1054+ let Some(message) = self.first_user_text() else { return };
1055+ if self.regenerating_title {
1056+ return;
1057+ }
1058+ self.regenerating_title = true;
1059+ cx.notify();
1060+ let task = cx.background_executor().spawn(async move { crate::textgen::title(&message) });
1061+ cx.spawn(async move |this, cx| {
1062+ let title = task.await;
1063+ let _ = this.update(cx, |this, cx| {
1064+ this.regenerating_title = false;
1065+ if let Some(title) = title {
1066+ this.title = title.into();
1067+ this.title_custom = false;
1068+ }
1069+ cx.notify();
1070+ });
1071+ })
1072+ .detach();
1073+ }
1074+
1075+ /// The text of the first message the user sent.
1076+ pub fn first_user_text(&self) -> Option<String> {
1077+ self.items.iter().find_map(|item| match item {
1078+ ChatItem::User { text, attachments, .. } if !text.trim().is_empty() || !attachments.is_empty() => {
1079+ Some(if text.trim().is_empty() { attachments.join(", ") } else { text.to_string() })
1080+ }
1081+ _ => None,
1082+ })
1083+ }
1084+
1085+ pub fn set_unread(&mut self, unread: bool, cx: &mut Context<Self>) {
1086+ if self.unread != unread {
1087+ self.unread = unread;
1088+ cx.notify();
1089+ }
1090+ }
1091+
1092+ /// Acknowledges a thread the user opened: it's read, and no longer woke.
1093+ pub fn mark_seen(&mut self, cx: &mut Context<Self>) {
1094+ if self.unread || self.woke {
1095+ self.unread = false;
1096+ self.woke = false;
1097+ cx.notify();
1098+ }
1099+ }
1100+
1101+ pub fn dismiss_woke(&mut self, cx: &mut Context<Self>) {
1102+ if self.woke {
1103+ self.woke = false;
1104+ cx.notify();
1105+ }
1106+ }
1107+
1108+ pub fn set_auto_settle(&mut self, on: bool, cx: &mut Context<Self>) {
1109+ self.auto_settle = on;
1110+ cx.notify();
1111+ }
1112+
1113+ /// Takes the thread out of the sidebar, or brings it back.
1114+ pub fn set_archived(&mut self, archived: bool, cx: &mut Context<Self>) {
1115+ self.archived = archived;
1116+ if archived {
1117+ self.pinned = false;
1118+ }
1119+ cx.notify();
1120+ }
1121+
1122+ /// Ends a snooze because its time came, so the sidebar says it woke.
1123+ pub fn wake(&mut self, cx: &mut Context<Self>) {
1124+ self.snooze(None, cx);
1125+ self.woke = true;
1126+ cx.notify();
1127+ }
1128+
10301129 pub fn rename(&mut self, title: String, cx: &mut Context<Self>) {
10311130 self.forward(ThreadAction::Rename { title: title.trim().to_string() });
10321131 let title = title.trim();
@@ -1068,6 +1167,39 @@ impl Session {
10681167 cx.notify();
10691168 }
10701169
1170+ /// Stops the agent process; the next message starts a fresh one that
1171+ /// resumes the conversation. Does nothing mid-turn.
1172+ pub fn restart_agent(&mut self, cx: &mut Context<Self>) -> bool {
1173+ if self.remote.is_some() || self.is_working() || self.process.is_none() {
1174+ return false;
1175+ }
1176+ self.process.take();
1177+ self._events_task = Task::ready(());
1178+ self.pending_controls.clear();
1179+ self.drop_background_tasks();
1180+ if self.status != SessionStatus::Exited {
1181+ self.status = SessionStatus::Idle;
1182+ }
1183+ cx.notify();
1184+ true
1185+ }
1186+
1187+ /// Moves a fresh thread into another thread's worktree, as "New thread on
1188+ /// {branch}" does for a worktree thread, restarting its agent there.
1189+ pub fn adopt_worktree(&mut self, worktree: Worktree, cwd: PathBuf, cx: &mut Context<Self>) {
1190+ if !self.items.is_empty() || self.remote.is_some() {
1191+ return;
1192+ }
1193+ self.process.take();
1194+ self._events_task = Task::ready(());
1195+ self.pending_controls.clear();
1196+ self.pending_worktree = None;
1197+ self.cwd = cwd;
1198+ self.worktree = Some(worktree);
1199+ self.start(None, cx);
1200+ cx.notify();
1201+ }
1202+
10711203 pub fn set_pinned(&mut self, pinned: bool, cx: &mut Context<Self>) {
10721204 self.forward(ThreadAction::Pin { pinned });
10731205 self.pinned = pinned;
@@ -1079,6 +1211,8 @@ impl Session {
10791211 self.snoozed_until = until;
10801212 if until.is_none() {
10811213 self.updated = SystemTime::now();
1214+ } else {
1215+ self.woke = false;
10821216 }
10831217 cx.notify();
10841218 }
@@ -1113,6 +1247,10 @@ impl Session {
11131247 /// Files the thread under Settled and stops its process. The transcript
11141248 /// stays, and the next message resumes the conversation.
11151249 pub fn settle(&mut self, cx: &mut Context<Self>) {
1250+ // Settling clears the pin: done beats keep on top.
1251+ self.pinned = false;
1252+ self.settled_at = Some(SystemTime::now());
1253+ self.woke = false;
11161254 if self.forward(ThreadAction::Settle) {
11171255 self.settled = true;
11181256 cx.notify();
@@ -1143,6 +1281,7 @@ impl Session {
11431281 pub fn unsettle(&mut self, cx: &mut Context<Self>) {
11441282 self.forward(ThreadAction::Unsettle);
11451283 self.settled = false;
1284+ self.settled_at = None;
11461285 self.updated = SystemTime::now();
11471286 cx.notify();
11481287 }
@@ -2691,6 +2830,16 @@ pub struct SavedThread {
26912830 pub settled: bool,
26922831 pub pinned: bool,
26932832 pub snoozed_until: Option<u64>,
2833+ #[serde(default)]
2834+ pub settled_at: Option<u64>,
2835+ #[serde(default)]
2836+ pub unread: bool,
2837+ #[serde(default)]
2838+ pub woke: bool,
2839+ #[serde(default = "default_true")]
2840+ pub auto_settle: bool,
2841+ #[serde(default)]
2842+ pub archived: bool,
26942843 pub limit_resets: Option<u64>,
26952844 pub resume_at: Option<u64>,
26962845 pub queue: Vec<QueuedMessage>,
@@ -2742,6 +2891,10 @@ pub enum SavedItem {
27422891 TurnEnd { duration_ms: u64, cost: Option<f64>, at: u64 },
27432892 }
27442893
2894+ fn default_true() -> bool {
2895+ true
2896+ }
2897+
27452898 pub fn to_millis(time: SystemTime) -> u64 {
27462899 time.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
27472900 }
@@ -2848,6 +3001,11 @@ impl Session {
28483001 settled: self.settled,
28493002 pinned: self.pinned,
28503003 snoozed_until: self.snoozed_until.map(to_millis),
3004+ settled_at: self.settled_at.map(to_millis),
3005+ unread: self.unread,
3006+ woke: self.woke,
3007+ auto_settle: self.auto_settle,
3008+ archived: self.archived,
28513009 limit_resets: self.limit_resets.map(to_millis),
28523010 resume_at: self.resume_at.map(to_millis),
28533011 queue: self.queue.clone(),
@@ -2887,6 +3045,11 @@ impl Session {
28873045 session.settled = saved.settled;
28883046 session.pinned = saved.pinned;
28893047 session.snoozed_until = saved.snoozed_until.map(from_millis);
3048+ session.settled_at = saved.settled_at.map(from_millis);
3049+ session.unread = saved.unread;
3050+ session.woke = saved.woke;
3051+ session.auto_settle = saved.auto_settle;
3052+ session.archived = saved.archived;
28903053 session.limit_resets = saved.limit_resets.map(from_millis);
28913054 session.resume_at = saved.resume_at.map(from_millis);
28923055 session.queue = saved.queue;
+68−1src/settings.rsView File
@@ -48,6 +48,54 @@ pub enum ProjectOrder {
4848 LastMessage,
4949 Name,
5050 Added,
51+ /// The order projects were added in, as the legacy sidebar's Manual sort.
52+ Manual,
53+ }
54+
55+ /// Which projects share a row in the legacy sidebar.
56+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57+ #[serde(rename_all = "snake_case")]
58+ pub enum ProjectGrouping {
59+ /// Projects from the same repository share one row.
60+ #[default]
61+ Repository,
62+ /// Projects group only when both the repository and the path inside it match.
63+ RepositoryPath,
64+ /// Every project path gets its own row.
65+ Separate,
66+ }
67+
68+ impl ProjectGrouping {
69+ pub const ALL: [ProjectGrouping; 3] =
70+ [ProjectGrouping::Repository, ProjectGrouping::RepositoryPath, ProjectGrouping::Separate];
71+
72+ pub fn label(self) -> &'static str {
73+ match self {
74+ ProjectGrouping::Repository => "Group by repository",
75+ ProjectGrouping::RepositoryPath => "Group by repository path",
76+ ProjectGrouping::Separate => "Keep separate",
77+ }
78+ }
79+
80+ pub fn description(self) -> &'static str {
81+ match self {
82+ ProjectGrouping::Repository => "Projects from the same repository share one sidebar row.",
83+ ProjectGrouping::RepositoryPath => {
84+ "Projects group only when both the repository and repo-relative path match."
85+ }
86+ ProjectGrouping::Separate => "Every project path gets its own sidebar row.",
87+ }
88+ }
89+ }
90+
91+ /// How the legacy sidebar orders the threads under each project.
92+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
93+ #[serde(rename_all = "snake_case")]
94+ pub enum ThreadOrder {
95+ /// The thread with the most recent message first.
96+ #[default]
97+ LastMessage,
98+ Created,
5199 }
52100
53101 /// Which of the two chosen themes shows.
@@ -248,9 +296,21 @@ pub struct Settings {
248296 pub diff_side_by_side: bool,
249297
250298 // ---- sidebar ----
299+ /// T3 Code's legacy sidebar: threads in per-project trees instead of the
300+ /// default flat inbox.
301+ pub legacy_sidebar: bool,
302+ /// How the legacy sidebar orders threads under a project.
303+ pub thread_order: ThreadOrder,
304+ /// How many threads the legacy sidebar shows per project before "Show more".
305+ pub visible_threads: u32,
306+ /// Which projects share a row in the legacy sidebar.
307+ pub project_grouping: ProjectGrouping,
308+ /// Per-project grouping rules, by project path, overriding the default.
309+ pub project_grouping_overrides: std::collections::BTreeMap<String, ProjectGrouping>,
251310 /// One-line thread cards in the sidebar.
252311 pub compact_threads: bool,
253- /// Group sidebar threads under their project.
312+ /// Group sidebar threads under their project: turns on the legacy
313+ /// sidebar, like `legacy_sidebar`.
254314 pub group_by_project: bool,
255315 pub project_order: ProjectOrder,
256316 /// Fold working threads into their own section until they need you.
@@ -266,6 +326,7 @@ pub struct Settings {
266326
267327 // ---- confirmations ----
268328 pub confirm_unpin: bool,
329+ pub confirm_archive: bool,
269330 /// Settling from a thread card takes a second click.
270331 pub confirm_settle: bool,
271332 pub confirm_delete: bool,
@@ -342,6 +403,11 @@ impl Default for Settings {
342403 hide_whitespace: true,
343404 diff_collapsed: false,
344405 diff_side_by_side: false,
406+ legacy_sidebar: false,
407+ thread_order: ThreadOrder::LastMessage,
408+ visible_threads: 6,
409+ project_grouping: ProjectGrouping::Repository,
410+ project_grouping_overrides: std::collections::BTreeMap::new(),
345411 compact_threads: false,
346412 group_by_project: false,
347413 project_order: ProjectOrder::LastMessage,
@@ -352,6 +418,7 @@ impl Default for Settings {
352418 auto_settle_inactive: false,
353419 inactive_days: 3,
354420 confirm_unpin: false,
421+ confirm_archive: false,
355422 confirm_settle: false,
356423 confirm_delete: true,
357424 quit_shortcut: QuitShortcut::Hold,
+8−0src/store.rsView File
@@ -24,6 +24,14 @@ pub struct AppState {
2424 pub projects: Vec<SavedProject>,
2525 /// The thread that was open.
2626 pub active: Option<String>,
27+ /// The sidebar's project filter, by project path.
28+ pub project_scope: Option<PathBuf>,
29+ /// Which of the sidebar's shelves are unfolded.
30+ pub working_expanded: bool,
31+ pub snoozed_expanded: bool,
32+ pub settled_expanded: bool,
33+ /// Projects folded away in the legacy sidebar.
34+ pub collapsed_projects: Vec<PathBuf>,
2735 }
2836
2937 fn threads_dir() -> Option<PathBuf> {
+5−0src/ui/chat.rsView File
@@ -225,6 +225,11 @@ impl ChatView {
225225 self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx));
226226 }
227227
228+ /// What's typed and attached but not sent, for the sidebar's draft rows.
229+ pub fn draft(&self, cx: &App) -> (String, usize) {
230+ (self.composer.read(cx).value().to_string(), self.attachments.len())
231+ }
232+
228233 /// Sends what's in the composer. While the agent works, the follow-up
229234 /// setting decides between steering it now and queueing for after the
230235 /// turn; `opposite` flips that for this one message.
+25−0src/ui/icons.rsView File
@@ -60,6 +60,31 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
6060 Star,
6161 Trash,
6262 Zap,
63+ Clock,
64+ CircleDashed,
65+ ShieldQuestionMark,
66+ Timer,
67+ FolderTree,
68+ Hash,
69+ MailOpen,
70+ MessageSquarePlus,
71+ Terminal,
72+ ChartNoAxesColumn,
73+ SquareMenu,
74+ Maximize2,
75+ Minimize2,
76+ MessageSquare,
77+ MessageSquareDashed,
78+ Monitor,
79+ Link,
80+ ArrowUpDown,
81+ X,
82+ ArrowRightLeft,
83+ GitPullRequestArrow,
84+ GitMerge,
85+ GitPullRequestClosed,
86+ GitPullRequestDraft,
87+ ListFilter,
6388 ]);
6489
6590 /// The app's asset source: the extra icons, then gpui-component's defaults.
+12−0src/ui/settings.rsView File
@@ -219,6 +219,18 @@ impl SettingsView {
219219 cx.notify();
220220 }
221221
222+ /// Shows the page with this name (`Usage`, `Keybindings`), as the
223+ /// command palette and the sidebar's Usage button ask.
224+ pub fn open_page(&mut self, label: &str, cx: &mut Context<Self>) {
225+ if let Some(page) = Page::ALL.into_iter().find(|page| page.label() == label) {
226+ self.page = page;
227+ if page == Page::Usage {
228+ self.refresh_usage(cx);
229+ }
230+ cx.notify();
231+ }
232+ }
233+
222234 fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
223235 let (dir, port) = settings::get(|settings| (settings.add_project_dir.clone(), settings.remote_port));
224236 self.add_project_dir.update(cx, |input, cx| input.set_value(dir, window, cx));
+425−827src/ui/workspace.rsView File
@@ -15,10 +15,9 @@ use std::{
1515
1616 use gpui_kit::component::{
1717 ActiveTheme as _, Icon, IconName, WindowExt as _,
18- button::ButtonVariant,
1918 h_flex,
20- input::{Input, InputEvent, InputState},
21- menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem},
19+ input::{InputEvent, InputState},
20+ menu::{DropdownMenu as _, PopupMenu, PopupMenuItem},
2221 notification::Notification,
2322 v_flex,
2423 };
@@ -27,24 +26,32 @@ use gpui_kit::*;
2726
2827 use crate::browser;
2928 use crate::claude::PermissionMode;
30- use crate::clock;
3129 use crate::git::{self, GitInfo};
3230 use crate::models::{Catalog, ModelEntry, Provider, split_key};
33- use crate::session::{NewSession, Reasoning, Session, SessionStatus, from_millis, to_millis};
31+ use crate::session::{NewSession, Reasoning, Session, from_millis, to_millis};
3432 use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
3533 use crate::store::{self, AppState, SavedProject};
36- use crate::ui::browser::{BrowserPanel, BrowserPanelEvent, BrowserWatch};
34+ use crate::ui::browser::{BrowserPanel, BrowserPanelEvent};
3735 use crate::ui::chat::{ChatEvent, ChatView, display_path};
3836 use crate::ui::folder_browser::{self, FolderBrowser, FolderBrowserEvent};
3937 use crate::ui::icons::Lucide;
40- use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
41- use crate::ui::pickers;
38+ use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
4239 use crate::ui::settings::{SettingsEvent, SettingsView};
4340 use crate::worktree::{self, WorktreePlan};
44- use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleBrowser, ToggleProjectPanel, ToggleSidebar};
41+ use crate::{
42+ CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread,
43+ OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, SettleThread, ToggleBrowser,
44+ ToggleCommandPalette, ToggleProjectPanel, ToggleRightPanel, ToggleSidebar, UndoThreadAction,
45+ };
4546
47+ mod actions;
48+ mod header;
49+ mod palette;
4650 mod remote;
51+ mod sidebar;
4752
53+ use actions::UndoEntry;
54+ use palette::CommandPalette;
4855 use remote::RemoteState;
4956
5057 pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
@@ -55,6 +62,8 @@ const PANEL_MIN_WINDOW: f32 = 1120.;
5562 pub(crate) const SHEET_INSET: f32 = 8.;
5663 pub(crate) const SHEET_RADIUS: f32 = 24.;
5764 const TICK: Duration = Duration::from_secs(2);
65+ /// How long Ctrl is held before the Ctrl+1–9 hints show.
66+ const JUMP_HINT_DELAY: Duration = Duration::from_millis(200);
5867 const GIT_POLL: Duration = Duration::from_secs(4);
5968 const HOUSEKEEPING: Duration = Duration::from_secs(15);
6069 const PR_CHECK: Duration = Duration::from_secs(300);
@@ -124,11 +133,35 @@ struct Project {
124133 git: Option<GitInfo>,
125134 /// The remote connection it's on, by id; `None` on this device.
126135 environment: Option<String>,
136+ /// The repository it's in (its common directory) and its path inside
137+ /// the checkout, for grouping projects in the legacy sidebar.
138+ repo: Option<(PathBuf, PathBuf)>,
127139 }
128140
129141 /// A thread in the sidebar: its project's index and the session.
130142 type ThreadEntry = (usize, Entity<Session>);
131143
144+ /// Names a project across changes to the list: its path, and the remote
145+ /// connection it's on (`None` for this device).
146+ pub(crate) type ProjectKey = (PathBuf, Option<String>);
147+
148+ /// A thread title being edited in place, in the sidebar or the chat header.
149+ struct InlineRename {
150+ session: Entity<Session>,
151+ input: Entity<InputState>,
152+ /// In the sidebar row, rather than the chat header.
153+ sidebar: bool,
154+ _subscription: Subscription,
155+ }
156+
157+ /// A menu opened from code rather than a right click: the chat header's
158+ /// thread menu, the project scope picker's settings, and the like.
159+ struct OverlayMenu {
160+ menu: Entity<PopupMenu>,
161+ position: Point<Pixels>,
162+ _dismiss: Subscription,
163+ }
164+
132165 /// A settled thread, as the settings' archive lists it.
133166 pub struct SettledThread {
134167 pub session: Entity<Session>,
@@ -170,8 +203,35 @@ pub struct Workspace {
170203 settled_open: bool,
171204 snoozed_open: bool,
172205 working_open: bool,
173- /// Projects folded away in the grouped thread list.
206+ /// How many settled threads the Settled shelf lists before "Show N more".
207+ settled_shown: usize,
208+ /// Projects folded away in the legacy sidebar.
174209 collapsed_projects: HashSet<PathBuf>,
210+ /// Projects whose full thread list the legacy sidebar shows ("Show more").
211+ expanded_projects: HashSet<PathBuf>,
212+ /// The project the sidebar is filtered to; `None` shows all projects.
213+ project_scope: Option<ProjectKey>,
214+ /// The project scope picker, while open, with its search field.
215+ scope_picker: Option<(Entity<InputState>, Subscription)>,
216+ /// Threads picked with Ctrl+click or Shift+click, in the order picked.
217+ selection: Vec<EntityId>,
218+ /// Where a Shift+click range starts.
219+ selection_anchor: Option<EntityId>,
220+ /// The highlighted row of the sidebar's search results.
221+ search_highlight: usize,
222+ renaming: Option<InlineRename>,
223+ overlay_menu: Option<OverlayMenu>,
224+ /// The command palette, while open.
225+ palette: Option<(Entity<CommandPalette>, Subscription)>,
226+ /// Actions the undo notice can take back, oldest first.
227+ undo: Vec<UndoEntry>,
228+ undo_generation: u64,
229+ undo_timer: Task<()>,
230+ /// When Ctrl went down, for showing the Ctrl+1–9 jump hints after a beat.
231+ ctrl_held: Option<Instant>,
232+ jump_hints: bool,
233+ /// A title click waiting to see whether it becomes a double-click.
234+ title_click: Task<()>,
175235 editors: Rc<Vec<EditorApp>>,
176236 editor: usize,
177237 hostname: SharedString,
@@ -182,6 +242,10 @@ pub struct Workspace {
182242 activity: HashMap<EntityId, Activity>,
183243 /// The thread whose settle button waits for its confirming click.
184244 settle_armed: Option<(EntityId, Instant)>,
245+ /// The legacy row whose Archive button waits for its Confirm click.
246+ archive_armed: Option<(EntityId, Instant)>,
247+ /// Where the chat header's title sits, to open its menu under it.
248+ title_bounds: Rc<std::cell::Cell<Bounds<Pixels>>>,
185249 /// Threads changed since they were last saved.
186250 dirty: HashSet<EntityId>,
187251 /// `state.json` as last written.
@@ -233,10 +297,13 @@ impl Workspace {
233297 cx.notify();
234298 }
235299 }),
236- cx.subscribe(&search, |_, _, event: &InputEvent, cx| {
237- if matches!(event, InputEvent::Change) {
300+ cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event {
301+ InputEvent::Change => {
302+ this.search_highlight = 0;
238303 cx.notify();
239304 }
305+ InputEvent::PressEnter { .. } => this.open_search_result(window, cx),
306+ _ => {}
240307 }),
241308 ];
242309 let ticker = cx.spawn_in(window, async move |this, cx| {
@@ -275,7 +342,23 @@ impl Workspace {
275342 settled_open: false,
276343 snoozed_open: false,
277344 working_open: false,
345+ settled_shown: sidebar::SETTLED_PAGE,
278346 collapsed_projects: HashSet::new(),
347+ expanded_projects: HashSet::new(),
348+ project_scope: None,
349+ scope_picker: None,
350+ selection: Vec::new(),
351+ selection_anchor: None,
352+ search_highlight: 0,
353+ renaming: None,
354+ overlay_menu: None,
355+ palette: None,
356+ undo: Vec::new(),
357+ undo_generation: 0,
358+ undo_timer: Task::ready(()),
359+ ctrl_held: None,
360+ jump_hints: false,
361+ title_click: Task::ready(()),
279362 editors: Rc::new(editors),
280363 editor,
281364 hostname: hostname().into(),
@@ -284,6 +367,8 @@ impl Workspace {
284367 folder_browser: None,
285368 activity: HashMap::new(),
286369 settle_armed: None,
370+ archive_armed: None,
371+ title_bounds: Rc::default(),
287372 dirty: HashSet::new(),
288373 saved_state: String::new(),
289374 worktree_git: HashMap::new(),
@@ -327,6 +412,10 @@ impl Workspace {
327412 /// open.
328413 fn restore(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<ThreadEntry> {
329414 let state = store::load_state();
415+ self.working_open = state.working_expanded;
416+ self.snoozed_open = state.snoozed_expanded;
417+ self.settled_open = state.settled_expanded;
418+ self.collapsed_projects = state.collapsed_projects.iter().cloned().collect();
330419 for project in &state.projects {
331420 let name = Some(project.name.as_str()).filter(|name| !name.is_empty());
332421 let ix = self.ensure_project(project.path.clone(), name);
@@ -350,6 +439,10 @@ impl Workspace {
350439 session.update(cx, |session, cx| session.send_message(CONTINUE_AFTER_RESTART.into(), Vec::new(), cx));
351440 }
352441 }
442+ self.project_scope = state
443+ .project_scope
444+ .filter(|path| self.projects.iter().any(|project| project.environment.is_none() && &project.path == path))
445+ .map(|path| (path, None));
353446 self.saved_state = serde_json::to_string(&self.app_state(cx)).unwrap_or_default();
354447 active
355448 }
@@ -368,6 +461,15 @@ impl Workspace {
368461 })
369462 .collect(),
370463 active: self.active.as_ref().map(|session| session.read(cx).id.clone()),
464+ project_scope: self.project_scope.as_ref().filter(|(_, env)| env.is_none()).map(|(path, _)| path.clone()),
465+ working_expanded: self.working_open,
466+ snoozed_expanded: self.snoozed_open,
467+ settled_expanded: self.settled_open,
468+ collapsed_projects: {
469+ let mut paths: Vec<PathBuf> = self.collapsed_projects.iter().cloned().collect();
470+ paths.sort();
471+ paths
472+ },
371473 }
372474 }
373475
@@ -444,6 +546,17 @@ impl Workspace {
444546 self.settle_armed = None;
445547 cx.notify();
446548 }
549+ if let Some((_, armed)) = self.archive_armed
550+ && armed.elapsed() >= SETTLE_ARMED
551+ {
552+ self.archive_armed = None;
553+ cx.notify();
554+ }
555+ // Working threads show how long they've worked.
556+ let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working());
557+ if working && self.sidebar_open {
558+ cx.notify();
559+ }
447560 // Repaint about once a minute so thread ages stay current.
448561 if self.last_repaint.elapsed() >= Duration::from_secs(60) {
449562 self.last_repaint = Instant::now();
@@ -524,14 +637,16 @@ impl Workspace {
524637 state.resume_at.is_some_and(|at| at <= now) && !state.is_working(),
525638 settle_inactive
526639 && idle
640+ && state.auto_settle
527641 && !state.settled
642+ && !state.archived
528643 && !state.pinned
529644 && state.resume_at.is_none()
530645 && now.duration_since(state.updated).unwrap_or_default() >= inactive,
531646 )
532647 };
533648 if wake {
534- session.update(cx, |session, cx| session.snooze(None, cx));
649+ session.update(cx, |session, cx| session.wake(cx));
535650 }
536651 if resume {
537652 session.update(cx, |session, cx| {
@@ -578,6 +693,7 @@ impl Workspace {
578693 let _ = session.update(cx, |session, cx| {
579694 let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open());
580695 let settle = was_open
696+ && session.auto_settle
581697 && !session.is_working()
582698 && match pull_request.state.as_str() {
583699 "MERGED" => merged_settles,
@@ -808,6 +924,11 @@ impl Workspace {
808924 return;
809925 }
810926 let failed = state.ended_with_error();
927+ let is_open = self.active.as_ref() == Some(&session) && !self.settings_open && window.is_window_active();
928+ if before.working && !now.working && !is_open && !state.unread {
929+ session.update(cx, |session, cx| session.set_unread(true, cx));
930+ }
931+ let state = session.read(cx);
811932 let event = if now.attention && !before.attention {
812933 "Waiting for you"
813934 } else if before.working && !now.working {
@@ -927,7 +1048,9 @@ impl Workspace {
9271048 .unwrap_or_else(|| path.display().to_string())
9281049 });
9291050 let git = git::read_info(&path);
1051+ let repo = git.as_ref().and_then(|_| git::repository_of(&path));
9301052 self.projects.push(Project {
1053+ repo,
9311054 path,
9321055 initials: initials(&name).into(),
9331056 tint: tint_for(&name),
@@ -946,6 +1069,7 @@ impl Workspace {
9461069 match settings::get(|settings| settings.project_order) {
9471070 ProjectOrder::Name => order.sort_by_key(|&ix| self.projects[ix].name.to_lowercase()),
9481071 ProjectOrder::Added => order.sort_by_key(|&ix| std::cmp::Reverse(self.projects[ix].added)),
1072+ ProjectOrder::Manual => {}
9491073 ProjectOrder::LastMessage => order.sort_by_key(|&ix| {
9501074 let project = &self.projects[ix];
9511075 let last = project
@@ -969,138 +1093,235 @@ impl Workspace {
9691093 cx: &mut Context<Self>,
9701094 ) {
9711095 self.active_project = project_ix;
972- session.update(cx, |session, _| session.subscribe_remote());
1096+ session.update(cx, |session, cx| {
1097+ session.subscribe_remote();
1098+ session.mark_seen(cx);
1099+ });
9731100 if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
9741101 chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
9751102 }
976- self.active = Some(session);
1103+ let previous = self.active.replace(session.clone());
1104+ if let Some(previous) = previous.filter(|previous| previous != &session) {
1105+ self.prune_empty_draft(&previous, cx);
1106+ }
1107+ if self.renaming.as_ref().is_some_and(|rename| !rename.sidebar) {
1108+ self.renaming = None;
1109+ }
9771110 self.last_git = Instant::now() - GIT_POLL;
9781111 cx.notify();
9791112 }
9801113
981- fn settle_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
982- self.settle_armed = None;
983- session.update(cx, |session, cx| session.settle(cx));
984- cx.notify();
1114+ /// Forgets a new thread left without anything sent or typed, as T3 drops
1115+ /// an empty draft once you move on.
1116+ fn prune_empty_draft(&mut self, session: &Entity<Session>, cx: &mut Context<Self>) {
1117+ if !session.read(cx).is_blank() || session.read(cx).is_remote() {
1118+ return;
1119+ }
1120+ let empty = self
1121+ .chats
1122+ .get(&session.entity_id())
1123+ .is_none_or(|chat| {
1124+ let (text, attachments) = chat.read(cx).draft(cx);
1125+ text.trim().is_empty() && attachments == 0
1126+ });
1127+ if empty && let Some(ix) = self.project_of(session) {
1128+ self.remove_thread(ix, session, cx);
1129+ }
9851130 }
9861131
987- /// The settle button on a thread card: settles at once, or arms itself
988- /// for a confirming second click.
989- fn press_settle(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
1132+ /// The key naming a project.
1133+ fn project_key(&self, ix: usize) -> ProjectKey {
1134+ let project = &self.projects[ix];
1135+ (project.path.clone(), project.environment.clone())
1136+ }
1137+
1138+ fn project_by_key(&self, key: &ProjectKey) -> Option<usize> {
1139+ self.projects.iter().position(|project| project.path == key.0 && project.environment == key.1)
1140+ }
1141+
1142+ /// Takes a thread out of its project without stopping it, so the undo
1143+ /// notice can put it back.
1144+ fn detach_thread(&mut self, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) {
9901145 let id = session.entity_id();
991- let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
992- if settings::get(|settings| settings.confirm_settle) && !armed {
993- self.settle_armed = Some((id, Instant::now()));
994- cx.notify();
995- } else {
996- self.settle_thread(session, cx);
1146+ self.chats.remove(&id);
1147+ self.observers.remove(&id);
1148+ self.activity.remove(&id);
1149+ if let Some(project) = self.projects.get_mut(project_ix) {
1150+ project.sessions.retain(|candidate| candidate.entity_id() != id);
9971151 }
1152+ cx.notify();
9981153 }
9991154
1000- /// Deletes a thread, asking first when Delete confirmation is on.
1001- fn request_delete(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1002- if !settings::get(|settings| settings.confirm_delete) {
1003- self.forget_thread(session, window, cx);
1155+ /// Puts a detached thread back where it was.
1156+ fn reattach_thread(
1157+ &mut self,
1158+ key: &ProjectKey,
1159+ session: Entity<Session>,
1160+ chat: Entity<ChatView>,
1161+ window: &mut Window,
1162+ cx: &mut Context<Self>,
1163+ ) {
1164+ let Some(project_ix) = self.project_by_key(key) else {
1165+ session.update(cx, |session, _| session.shutdown());
10041166 return;
1005- }
1006- let title = session.read(cx).title.clone();
1167+ };
1168+ self.observers.insert(
1169+ session.entity_id(),
1170+ vec![cx.observe_in(&session, window, Self::on_session_changed), cx.subscribe_in(&chat, window, Self::on_chat_event)],
1171+ );
1172+ self.chats.insert(session.entity_id(), chat);
1173+ self.projects[project_ix].sessions.push(session);
1174+ cx.notify();
1175+ }
1176+
1177+ /// Opens a menu at a point in the window, as a right click would.
1178+ fn open_menu(
1179+ &mut self,
1180+ position: Point<Pixels>,
1181+ window: &mut Window,
1182+ cx: &mut Context<Self>,
1183+ build: impl FnOnce(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
1184+ ) {
1185+ // The menus read the workspace as they build, so build once this
1186+ // update is over.
10071187 let workspace = cx.weak_entity();
1008- window.open_alert_dialog(cx, move |alert, _, _| {
1009- let workspace = workspace.clone();
1010- let session = session.clone();
1011- alert
1012- .title("Delete thread?")
1013- .description(format!("\u{201c}{title}\u{201d} and its chat history will be deleted."))
1014- .ok_text("Delete")
1015- .ok_variant(ButtonVariant::Danger)
1016- .show_cancel(true)
1017- .on_ok(move |_, window, cx| {
1018- let _ = workspace.update(cx, |this, cx| this.forget_thread(session.clone(), window, cx));
1019- true
1020- })
1188+ window.defer(cx, move |window, cx| {
1189+ let menu = PopupMenu::build(window, cx, build);
1190+ menu.focus_handle(cx).focus(window, cx);
1191+ let _ = workspace.update(cx, |this, cx| {
1192+ let dismiss = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, window, cx| {
1193+ this.overlay_menu = None;
1194+ // Hand focus back, so the shortcuts keep working.
1195+ match this.active.as_ref().and_then(|session| this.chats.get(&session.entity_id())) {
1196+ Some(chat) if this.renaming.is_none() => chat.update(cx, |chat, cx| chat.focus_composer(window, cx)),
1197+ _ if this.renaming.is_none() => this.focus_handle.focus(window, cx),
1198+ _ => {}
1199+ }
1200+ cx.notify();
1201+ });
1202+ this.overlay_menu = Some(OverlayMenu { menu, position, _dismiss: dismiss });
1203+ cx.notify();
1204+ });
10211205 });
10221206 }
10231207
1024- /// Pins or unpins a thread, asking before unpinning when set to.
1025- fn toggle_pin(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1026- let pinned = session.read(cx).pinned;
1027- if !pinned || !settings::get(|settings| settings.confirm_unpin) {
1028- session.update(cx, |session, cx| session.set_pinned(!pinned, cx));
1029- return;
1208+ /// Edits a thread's title in place: in its sidebar row, or in the chat
1209+ /// header. Enter or leaving the field saves, Esc cancels.
1210+ fn start_rename(&mut self, session: Entity<Session>, sidebar: bool, window: &mut Window, cx: &mut Context<Self>) {
1211+ // A row that isn't showing can't host the field; the header can.
1212+ let sidebar = sidebar && self.sidebar_open && !self.settings_open;
1213+ if !sidebar && self.active.as_ref() != Some(&session) {
1214+ if let Some(ix) = self.project_of(&session) {
1215+ self.select(ix, session.clone(), window, cx);
1216+ }
10301217 }
1031- let title = session.read(cx).title.clone();
1032- window.open_alert_dialog(cx, move |alert, _, _| {
1033- let session = session.clone();
1034- alert
1035- .title("Unpin thread?")
1036- .description(format!("\u{201c}{title}\u{201d} moves back among the other threads."))
1037- .ok_text("Unpin")
1038- .show_cancel(true)
1039- .on_ok(move |_, _, cx| {
1040- session.update(cx, |session, cx| session.set_pinned(false, cx));
1041- true
1042- })
1218+ let title = session.read(cx).title.to_string();
1219+ let input = cx.new(|cx| InputState::new(window, cx).default_value(title));
1220+ input.update(cx, |input, cx| {
1221+ input.focus(window, cx);
1222+ input.select_all(window, cx);
10431223 });
1224+ let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event {
1225+ InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx),
1226+ _ => {}
1227+ });
1228+ self.renaming = Some(InlineRename { session, input, sidebar, _subscription: subscription });
1229+ cx.notify();
10441230 }
10451231
1046- fn rename_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1047- let title = session.read(cx).title.to_string();
1048- let input = cx.new(|cx| InputState::new(window, cx).default_value(title));
1049- input.update(cx, |input, cx| input.focus(window, cx));
1050- let rename = {
1051- let input = input.clone();
1052- let session = session.clone();
1053- move |window: &mut Window, cx: &mut App| {
1054- let title = input.read(cx).value().to_string();
1055- session.update(cx, |session, cx| session.rename(title, cx));
1056- window.close_dialog(cx);
1057- }
1232+ fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1233+ let Some(rename) = self.renaming.take() else { return };
1234+ let title = rename.input.read(cx).value().trim().to_string();
1235+ if title.is_empty() {
1236+ window.push_notification(Notification::warning("Thread title cannot be empty"), cx);
1237+ } else if title != rename.session.read(cx).title.as_ref() {
1238+ rename.session.update(cx, |session, cx| session.rename(title, cx));
1239+ }
1240+ cx.notify();
1241+ }
1242+
1243+ fn cancel_rename(&mut self, cx: &mut Context<Self>) {
1244+ if self.renaming.take().is_some() {
1245+ cx.notify();
1246+ }
1247+ }
1248+
1249+ /// Opens the previous or next thread in the sidebar (Ctrl+Shift+[ and ]).
1250+ fn step_thread(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
1251+ let rows = self.visible_rows(cx);
1252+ if rows.is_empty() {
1253+ return;
1254+ }
1255+ let current = self.active.as_ref().and_then(|active| rows.iter().position(|(_, row)| row == active));
1256+ let next = match current {
1257+ Some(ix) => (ix as isize + delta).rem_euclid(rows.len() as isize) as usize,
1258+ None if delta < 0 => rows.len() - 1,
1259+ None => 0,
10581260 };
1059- let on_enter = rename.clone();
1060- let subscription = cx.subscribe_in(&input, window, move |_, _, event: &InputEvent, window, cx| {
1061- if matches!(event, InputEvent::PressEnter { .. }) {
1062- on_enter(window, cx);
1261+ let (project, session) = rows[next].clone();
1262+ self.select(project, session, window, cx);
1263+ }
1264+
1265+ /// Ctrl+1–9 open the sidebar's first nine threads.
1266+ fn jump_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1267+ let keystroke = &event.keystroke;
1268+ let modifiers = &keystroke.modifiers;
1269+ if !modifiers.control || modifiers.shift || modifiers.alt || modifiers.platform || self.palette.is_some() {
1270+ return;
1271+ }
1272+ let Some(digit) = keystroke.key.parse::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else { return };
1273+ if let Some((project, session)) = self.visible_rows(cx).get(digit - 1).cloned() {
1274+ cx.stop_propagation();
1275+ self.jump_hints = false;
1276+ self.select(project, session, window, cx);
1277+ }
1278+ }
1279+
1280+ /// Shows the jump hints once Ctrl has been held for a beat.
1281+ fn modifiers_changed(&mut self, event: &ModifiersChangedEvent, window: &mut Window, cx: &mut Context<Self>) {
1282+ let held = event.modifiers.control && !event.modifiers.shift && !event.modifiers.alt;
1283+ if !held {
1284+ self.ctrl_held = None;
1285+ if self.jump_hints {
1286+ self.jump_hints = false;
1287+ cx.notify();
10631288 }
1064- });
1065- let rename = Rc::new(rename);
1066- window.open_dialog(cx, move |dialog, _, cx| {
1067- let p = metal::palette(cx);
1068- let rename = rename.clone();
1069- dialog
1070- .title("Rename thread")
1071- .w(px(420.))
1072- .child(
1073- h_flex()
1074- .h(px(36.))
1075- .px(px(11.))
1076- .rounded(px(12.))
1077- .well(p)
1078- .text_size(px(13.5))
1079- .child(div().flex_1().child(Input::new(&input).appearance(false))),
1080- )
1081- .footer(
1082- h_flex()
1083- .w_full()
1084- .justify_end()
1085- .gap(px(8.))
1086- .child(
1087- MetalButton::new("rename-cancel")
1088- .ghost()
1089- .compact()
1090- .label("Cancel")
1091- .on_click(|_, window, cx| window.close_dialog(cx)),
1092- )
1093- .child(
1094- MetalButton::new("rename-save")
1095- .primary()
1096- .compact()
1097- .label("Rename")
1098- .on_click(move |_, window, cx| rename(window, cx)),
1099- ),
1100- )
1101- });
1102- // The subscription lives as long as the dialog might.
1103- self._subscriptions.push(subscription);
1289+ return;
1290+ }
1291+ if self.ctrl_held.is_some() {
1292+ return;
1293+ }
1294+ let since = Instant::now();
1295+ self.ctrl_held = Some(since);
1296+ cx.spawn_in(window, async move |this, cx| {
1297+ cx.background_executor().timer(JUMP_HINT_DELAY).await;
1298+ let _ = this.update(cx, |this, cx| {
1299+ if this.ctrl_held == Some(since) {
1300+ this.jump_hints = true;
1301+ cx.notify();
1302+ }
1303+ });
1304+ })
1305+ .detach();
1306+ }
1307+
1308+ /// Opens a settings page by its name in the settings view (`Usage`).
1309+ fn open_settings_page(&mut self, page: &str, window: &mut Window, cx: &mut Context<Self>) {
1310+ self.open_settings(window, cx);
1311+ self.settings_view.update(cx, |view, cx| view.open_page(page, cx));
1312+ }
1313+
1314+ /// The settle button on a thread card: settles at once, or arms itself
1315+ /// for a confirming second click when Settle confirmation is on.
1316+ fn press_settle(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1317+ let id = session.entity_id();
1318+ let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
1319+ if settings::get(|settings| settings.confirm_settle) && !armed {
1320+ self.settle_armed = Some((id, Instant::now()));
1321+ cx.notify();
1322+ } else {
1323+ self.settle_threads(vec![session], window, cx);
1324+ }
11041325 }
11051326
11061327 fn delete_thread(
@@ -1320,13 +1541,14 @@ impl Workspace {
13201541 cx.notify();
13211542 }
13221543
1323- /// Every settled thread, most recently active first.
1544+ /// Every archived thread, most recently active first, for Settings →
1545+ /// Archive. (Settled threads stay in the sidebar's Settled shelf.)
13241546 pub fn settled_threads(&self, cx: &App) -> Vec<SettledThread> {
13251547 let mut threads: Vec<_> = self
13261548 .projects
13271549 .iter()
13281550 .flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
1329- .filter(|(_, session)| session.read(cx).settled)
1551+ .filter(|(_, session)| session.read(cx).archived)
13301552 .collect();
13311553 threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
13321554 threads
@@ -1349,7 +1571,10 @@ impl Workspace {
13491571 /// Brings a settled thread back and opens it.
13501572 pub fn restore_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
13511573 let Some(ix) = self.project_of(&session) else { return };
1352- session.update(cx, |session, cx| session.unsettle(cx));
1574+ session.update(cx, |session, cx| {
1575+ session.set_archived(false, cx);
1576+ session.unsettle(cx);
1577+ });
13531578 self.select(ix, session, window, cx);
13541579 self.close_settings(window, cx);
13551580 }
@@ -1360,619 +1585,6 @@ impl Workspace {
13601585 }
13611586 }
13621587
1363- // ---- sidebar --------------------------------------------------------
1364-
1365- fn render_sidebar_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
1366- let p = metal::palette(cx);
1367- h_flex()
1368- .flex_none()
1369- .h(px(52.))
1370- .pl(px(10.))
1371- .pr(px(12.))
1372- .gap(px(8.))
1373- .child(
1374- MetalButton::new("hide-sidebar")
1375- .ghost()
1376- .mini()
1377- .icon(Lucide::PanelLeftClose)
1378- .tooltip("Hide sidebar (Ctrl+B)")
1379- .on_click(cx.listener(|this, _, _, cx| {
1380- this.sidebar_open = false;
1381- cx.notify();
1382- })),
1383- )
1384- .child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
1385- .child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
1386- .child(metal::tag("Code", p))
1387- }
1388-
1389- fn render_sidebar_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1390- let p = metal::palette(cx);
1391- let projects: Vec<(usize, SharedString)> = self
1392- .ordered_projects(cx)
1393- .into_iter()
1394- .map(|ix| {
1395- let project = &self.projects[ix];
1396- let name = match &project.environment {
1397- Some(env) => format!("{} · {}", project.name, self.environment_name(env)).into(),
1398- None => project.name.clone(),
1399- };
1400- (ix, name)
1401- })
1402- .collect();
1403- let workspace = cx.entity().downgrade();
1404-
1405- h_flex()
1406- .flex_none()
1407- .px(px(12.))
1408- .gap(px(4.))
1409- .child(
1410- h_flex()
1411- .flex_1()
1412- .min_w_0()
1413- .h(px(36.))
1414- .pl(px(11.))
1415- .pr(px(8.))
1416- .gap(px(8.))
1417- .rounded(px(14.))
1418- .well(p)
1419- .text_size(px(13.))
1420- .text_color(p.ink)
1421- .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink))
1422- .child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))),
1423- )
1424- .child(
1425- MetalButton::new("project-menu")
1426- .ghost()
1427- .mini()
1428- .icon(Lucide::FolderPlus)
1429- .dropdown_menu(move |mut menu, _, _| {
1430- for (ix, name) in projects.clone() {
1431- let workspace = workspace.clone();
1432- menu = menu.item(PopupMenuItem::new(format!("New thread in {name}")).on_click(
1433- move |_, window, cx| {
1434- let _ = workspace.update(cx, |this, cx| this.new_thread(ix, window, cx));
1435- },
1436- ));
1437- }
1438- let workspace = workspace.clone();
1439- menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(
1440- move |_, window, cx| {
1441- let _ = workspace.update(cx, |this, cx| this.prompt_for_project(window, cx));
1442- },
1443- ))
1444- }),
1445- )
1446- .child(
1447- MetalButton::new("new-thread")
1448- .ghost()
1449- .mini()
1450- .icon(Lucide::SquarePen)
1451- .tooltip("New thread (Ctrl+N)")
1452- .on_click(cx.listener(|this, _, window, cx| {
1453- if this.projects.is_empty() {
1454- this.prompt_for_project(window, cx);
1455- } else {
1456- this.new_thread(this.active_project, window, cx);
1457- }
1458- })),
1459- )
1460- }
1461-
1462- /// The right-click menu of a thread card.
1463- fn thread_menu(
1464- workspace: WeakEntity<Self>,
1465- session: Entity<Session>,
1466- menu: PopupMenu,
1467- window: &mut Window,
1468- cx: &mut Context<PopupMenu>,
1469- ) -> PopupMenu {
1470- let state = session.read(cx);
1471- let (pinned, settled, snoozed) = (state.pinned, state.settled, state.is_snoozed());
1472- let action = |run: fn(&mut Workspace, Entity<Session>, &mut Window, &mut Context<Workspace>)| {
1473- let workspace = workspace.clone();
1474- let session = session.clone();
1475- move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
1476- let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx));
1477- }
1478- };
1479- let snooze_session = session.clone();
1480- let mut menu = menu
1481- .min_w(px(200.))
1482- .item(
1483- PopupMenuItem::new(if pinned { "Unpin" } else { "Pin to top" })
1484- .icon(Icon::new(if pinned { Lucide::PinOff } else { Lucide::Pin }))
1485- .on_click(action(|this, session, window, cx| this.toggle_pin(session, window, cx))),
1486- )
1487- .item(
1488- PopupMenuItem::new("Rename…")
1489- .icon(Icon::new(Lucide::Pencil))
1490- .on_click(action(|this, session, window, cx| this.rename_thread(session, window, cx))),
1491- );
1492- if !settled {
1493- menu = menu.submenu_with_icon(Some(Icon::new(Lucide::AlarmClock)), "Snooze", window, cx, move |mut menu, _, _| {
1494- let snooze = |until: fn() -> SystemTime| {
1495- let session = snooze_session.clone();
1496- move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
1497- session.update(cx, |session, cx| session.snooze(Some(until()), cx));
1498- }
1499- };
1500- menu = menu
1501- .min_w(px(220.))
1502- .item(PopupMenuItem::new("For an hour").on_click(snooze(|| SystemTime::now() + Duration::from_secs(3_600))))
1503- .item(PopupMenuItem::new("For three hours").on_click(snooze(|| SystemTime::now() + Duration::from_secs(10_800))))
1504- .item(PopupMenuItem::new(format!("Until tomorrow, {}", clock::clock(clock::at_hour(1, 9)))).on_click(snooze(|| clock::at_hour(1, 9))))
1505- .item(PopupMenuItem::new(format!("Until next week, {}", clock::clock(clock::at_hour(7, 9)))).on_click(snooze(|| clock::at_hour(7, 9))));
1506- menu
1507- });
1508- if snoozed {
1509- let session = session.clone();
1510- menu = menu.item(PopupMenuItem::new("Wake up now").icon(Icon::new(Lucide::AlarmClockOff)).on_click(
1511- move |_, _, cx| session.update(cx, |session, cx| session.snooze(None, cx)),
1512- ));
1513- }
1514- }
1515- menu = menu.separator();
1516- menu = if settled {
1517- menu.item(
1518- PopupMenuItem::new("Restore")
1519- .icon(Icon::new(Lucide::ArchiveRestore))
1520- .on_click(action(|this, session, window, cx| this.restore_thread(session, window, cx))),
1521- )
1522- } else {
1523- menu.item(
1524- PopupMenuItem::new("Settle")
1525- .icon(Icon::new(Lucide::Archive))
1526- .on_click(action(|this, session, _, cx| this.settle_thread(session, cx))),
1527- )
1528- };
1529- menu.item(
1530- PopupMenuItem::new("Delete…")
1531- .icon(Icon::new(Lucide::Trash))
1532- .on_click(action(|this, session, window, cx| this.request_delete(session, window, cx))),
1533- )
1534- }
1535-
1536- fn render_thread_card(
1537- &self,
1538- project_ix: usize,
1539- session: &Entity<Session>,
1540- cx: &mut Context<Self>,
1541- ) -> AnyElement {
1542- let p = metal::palette(cx);
1543- let project = &self.projects[project_ix];
1544- let state = session.read(cx);
1545- let id = session.entity_id();
1546- let selected = self.active.as_ref().is_some_and(|active| active.entity_id() == id);
1547- let (led, pulse) = if state.settled {
1548- (Led::Off, false)
1549- } else if state.needs_attention() {
1550- (Led::Waiting, true)
1551- } else if state.limited_until().is_some() {
1552- (Led::Waiting, false)
1553- } else {
1554- match state.status {
1555- SessionStatus::Working => (Led::Live, true),
1556- // Idle, but a background task will wake Claude.
1557- SessionStatus::Idle if !state.background_tasks.is_empty() => (Led::Live, true),
1558- SessionStatus::Idle => (Led::Live, false),
1559- SessionStatus::Starting => (Led::Off, false),
1560- SessionStatus::Exited => (Led::Failed, false),
1561- }
1562- };
1563- let branch = match &state.worktree {
1564- Some(worktree) => Some(worktree.branch.clone()),
1565- None => project.git.as_ref().and_then(|git| git.branch.clone()),
1566- };
1567- let in_worktree = state.worktree.is_some() || state.pending_worktree.is_some();
1568- let host = project.environment.as_deref().map(|env| self.environment_name(env));
1569- let settled = state.settled;
1570- let pinned = state.pinned;
1571- let snoozed_until = state.snoozed_until.filter(|_| state.is_snoozed());
1572- let group = SharedString::from(format!("thread-{id}"));
1573- let select_session = session.clone();
1574- let action_session = session.clone();
1575- let pin_session = session.clone();
1576- let compact = settings::get(|settings| settings.compact_threads);
1577- let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
1578-
1579- let settle_or_delete = if settled {
1580- MetalButton::new(("delete-thread", id.as_u64() as usize))
1581- .ghost()
1582- .mini()
1583- .icon(IconName::Close)
1584- .tooltip("Delete thread")
1585- .on_click(cx.listener(move |this, _, window, cx| {
1586- cx.stop_propagation();
1587- this.request_delete(action_session.clone(), window, cx)
1588- }))
1589- } else if armed {
1590- MetalButton::new(("settle-thread", id.as_u64() as usize))
1591- .danger()
1592- .mini()
1593- .icon(IconName::Check)
1594- .tooltip("Click again to settle")
1595- .on_click(cx.listener(move |this, _, _, cx| {
1596- cx.stop_propagation();
1597- this.press_settle(action_session.clone(), cx)
1598- }))
1599- } else {
1600- MetalButton::new(("settle-thread", id.as_u64() as usize))
1601- .ghost()
1602- .mini()
1603- .icon(Lucide::Archive)
1604- .tooltip("Settle: stop the agent and file the thread away")
1605- .on_click(cx.listener(move |this, _, _, cx| {
1606- cx.stop_propagation();
1607- this.press_settle(action_session.clone(), cx)
1608- }))
1609- };
1610- let pin_button = MetalButton::new(("pin-thread", id.as_u64() as usize))
1611- .ghost()
1612- .mini()
1613- .icon(if pinned { Lucide::PinOff } else { Lucide::Pin })
1614- .tooltip(if pinned { "Unpin" } else { "Pin to top" })
1615- .on_click(cx.listener(move |this, _, window, cx| {
1616- cx.stop_propagation();
1617- this.toggle_pin(pin_session.clone(), window, cx)
1618- }));
1619- let actions = h_flex()
1620- .gap(px(1.))
1621- .when(!armed, |this| this.invisible().group_hover(group.clone(), |this| this.visible()))
1622- .when(!settled, |this| this.child(pin_button))
1623- .child(settle_or_delete);
1624- let led = metal::led(("thread-led", id.as_u64() as usize), led, pulse, p);
1625- let title = h_flex()
1626- .min_w_0()
1627- .gap(px(5.))
1628- .when(pinned, |this| this.child(Icon::new(Lucide::Pin).size(px(11.)).text_color(p.ink3).flex_none()))
1629- .child(
1630- div()
1631- .min_w_0()
1632- .truncate()
1633- .text_size(px(13.5))
1634- .font_weight(FontWeight::SEMIBOLD)
1635- .text_color(if selected || !settled { p.ink } else { p.ink2 })
1636- .child(state.title.clone()),
1637- );
1638- let age = div()
1639- .flex_none()
1640- .font_family(metal::MONO)
1641- .text_size(px(10.5))
1642- .text_color(p.ink3)
1643- .child(match snoozed_until {
1644- Some(until) => clock::when(until),
1645- None => relative_time(state.updated),
1646- });
1647-
1648- let workspace = cx.weak_entity();
1649- let menu_session = session.clone();
1650- let card = v_flex()
1651- .id(("thread", id.as_u64() as usize))
1652- .group(group)
1653- .px(px(12.))
1654- .py(px(if compact { 5. } else { 9. }))
1655- .gap(px(4.))
1656- .rounded(px(if compact { 12. } else { 14. }))
1657- .cursor_pointer()
1658- .map(|this| {
1659- if selected {
1660- this.raised(p)
1661- } else {
1662- this.hover(|style| style.bg(p.row_hover))
1663- }
1664- })
1665- .on_click(cx.listener(move |this, _, window, cx| {
1666- this.select(project_ix, select_session.clone(), window, cx)
1667- }));
1668-
1669- let card = if compact {
1670- card.child(
1671- h_flex()
1672- .h(px(22.))
1673- .gap(px(7.))
1674- .child(metal::badge(project.initials.clone(), project.tint, p))
1675- .child(title.flex_1().text_size(px(13.)).font_weight(FontWeight::MEDIUM))
1676- .child(actions)
1677- .child(age)
1678- .child(led),
1679- )
1680- } else {
1681- card.child(
1682- h_flex()
1683- .gap(px(7.))
1684- .child(metal::badge(project.initials.clone(), project.tint, p))
1685- .child(
1686- div()
1687- .flex_1()
1688- .min_w_0()
1689- .truncate()
1690- .text_size(px(12.))
1691- .font_weight(FontWeight::MEDIUM)
1692- .text_color(p.ink3)
1693- .child(project.name.clone()),
1694- )
1695- .when_some(host, |this, host| this.child(metal::tag(host, p)))
1696- .when(snoozed_until.is_some(), |this| {
1697- this.child(Icon::new(Lucide::AlarmClock).size(px(11.)).text_color(p.ink3))
1698- })
1699- .child(age),
1700- )
1701- .child(title)
1702- .child(
1703- h_flex()
1704- .h(px(18.))
1705- .gap(px(6.))
1706- .text_color(p.ink3)
1707- .when_some(branch, |this, branch| {
1708- this.child(Icon::new(if in_worktree { Lucide::FolderGit2 } else { Lucide::GitBranch }).size(px(11.)))
1709- .child(
1710- div()
1711- .min_w_0()
1712- .truncate()
1713- .font_family(metal::MONO)
1714- .text_size(px(10.5))
1715- .child(branch),
1716- )
1717- })
1718- .child(div().flex_1())
1719- .child(pickers::provider_icon(state.provider, px(11.), p))
1720- .child(actions)
1721- .child(led),
1722- )
1723- };
1724- card.context_menu(move |menu, window, cx| {
1725- Self::thread_menu(workspace.clone(), menu_session.clone(), menu, window, cx)
1726- })
1727- .into_any_element()
1728- }
1729-
1730- /// A foldable section header in the thread list: `Settled (3) ——— ›`.
1731- fn render_fold(
1732- &self,
1733- id: &'static str,
1734- label: String,
1735- open: bool,
1736- toggle: fn(&mut Self),
1737- cx: &mut Context<Self>,
1738- ) -> AnyElement {
1739- let p = metal::palette(cx);
1740- h_flex()
1741- .id(id)
1742- .mt(px(10.))
1743- .h(px(30.))
1744- .px(px(12.))
1745- .gap(px(8.))
1746- .rounded(px(10.))
1747- .cursor_pointer()
1748- .text_size(px(12.5))
1749- .font_weight(FontWeight::MEDIUM)
1750- .text_color(p.ink3)
1751- .hover(|style| style.bg(p.row_hover).text_color(p.ink))
1752- .on_click(cx.listener(move |this, _, _, cx| {
1753- toggle(this);
1754- cx.notify();
1755- }))
1756- .child(label)
1757- .child(div().flex_1().h(px(1.)).bg(p.rule))
1758- .child(Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(12.)))
1759- .into_any_element()
1760- }
1761-
1762- /// A project's header in the grouped thread list.
1763- fn render_project_header(&self, ix: usize, count: usize, cx: &mut Context<Self>) -> AnyElement {
1764- let p = metal::palette(cx);
1765- let project = &self.projects[ix];
1766- let collapsed = self.collapsed_projects.contains(&project.path);
1767- let path = project.path.clone();
1768- h_flex()
1769- .id(("project-group", ix))
1770- .group(SharedString::from(format!("project-group-{ix}")))
1771- .mt(px(8.))
1772- .h(px(30.))
1773- .pl(px(10.))
1774- .pr(px(4.))
1775- .gap(px(7.))
1776- .rounded(px(10.))
1777- .cursor_pointer()
1778- .hover(|style| style.bg(p.row_hover))
1779- .on_click(cx.listener(move |this, _, _, cx| {
1780- if !this.collapsed_projects.remove(&path) {
1781- this.collapsed_projects.insert(path.clone());
1782- }
1783- cx.notify();
1784- }))
1785- .child(Icon::new(if collapsed { IconName::ChevronRight } else { IconName::ChevronDown }).size(px(12.)).text_color(p.ink3))
1786- .child(metal::badge(project.initials.clone(), project.tint, p))
1787- .child(
1788- div()
1789- .flex_1()
1790- .min_w_0()
1791- .truncate()
1792- .text_size(px(12.5))
1793- .font_weight(FontWeight::SEMIBOLD)
1794- .text_color(p.ink2)
1795- .child(project.name.clone()),
1796- )
1797- .child(div().font_family(metal::MONO).text_size(px(10.5)).text_color(p.ink3).child(count.to_string()))
1798- .child(
1799- div().invisible().group_hover(SharedString::from(format!("project-group-{ix}")), |this| this.visible()).child(
1800- MetalButton::new(("project-new-thread", ix))
1801- .ghost()
1802- .mini()
1803- .icon(Lucide::SquarePen)
1804- .tooltip(format!("New thread in {}", project.name))
1805- .on_click(cx.listener(move |this, _, window, cx| {
1806- cx.stop_propagation();
1807- this.new_thread(ix, window, cx);
1808- })),
1809- ),
1810- )
1811- .into_any_element()
1812- }
1813-
1814- fn render_thread_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
1815- let p = metal::palette(cx);
1816- let (open, settled) = self.threads(cx);
1817- let searching = !self.search.read(cx).value().trim().is_empty();
1818- let (group, working_section) = settings::get(|settings| (settings.group_by_project, settings.working_section));
1819-
1820- // Pinned threads stay on top; snoozed ones wait below; working ones
1821- // fold away (when set to) until they need the user.
1822- let mut pinned = Vec::new();
1823- let mut snoozed = Vec::new();
1824- let mut working = Vec::new();
1825- let mut rest = Vec::new();
1826- for entry in open {
1827- let state = entry.1.read(cx);
1828- if state.is_snoozed() {
1829- snoozed.push(entry);
1830- } else if state.pinned {
1831- pinned.push(entry);
1832- } else if working_section && state.is_working() && !state.needs_attention() {
1833- working.push(entry);
1834- } else {
1835- rest.push(entry);
1836- }
1837- }
1838- let is_empty = pinned.is_empty() && snoozed.is_empty() && working.is_empty() && rest.is_empty() && settled.is_empty();
1839- let mut children: Vec<AnyElement> = Vec::new();
1840- if !pinned.is_empty() {
1841- children.push(
1842- div()
1843- .px(px(12.))
1844- .pt(px(2.))
1845- .pb(px(4.))
1846- .child(metal::engraved(format!("Pinned · {}", pinned.len()), p))
1847- .into_any_element(),
1848- );
1849- children.extend(pinned.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1850- if !rest.is_empty() || !working.is_empty() {
1851- children.push(div().px(px(12.)).py(px(6.)).child(metal::rule(p)).into_any_element());
1852- }
1853- }
1854- if !working.is_empty() {
1855- let open = self.working_open || searching;
1856- children.push(self.render_fold(
1857- "working-toggle",
1858- format!("Working ({})", working.len()),
1859- open,
1860- |this| this.working_open = !this.working_open,
1861- cx,
1862- ));
1863- if open {
1864- children.extend(working.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1865- }
1866- }
1867- if group {
1868- for ix in self.ordered_projects(cx) {
1869- let threads: Vec<_> = rest.iter().filter(|(project, _)| *project == ix).collect();
1870- if threads.is_empty() {
1871- continue;
1872- }
1873- children.push(self.render_project_header(ix, threads.len(), cx));
1874- if searching || !self.collapsed_projects.contains(&self.projects[ix].path) {
1875- children.extend(threads.into_iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1876- }
1877- }
1878- } else {
1879- children.extend(rest.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1880- }
1881- for (id, label, threads, open, toggle) in [
1882- (
1883- "snoozed-toggle",
1884- "Snoozed",
1885- snoozed,
1886- self.snoozed_open || searching,
1887- (|this: &mut Self| this.snoozed_open = !this.snoozed_open) as fn(&mut Self),
1888- ),
1889- (
1890- "settled-toggle",
1891- "Settled",
1892- settled,
1893- self.settled_open || searching,
1894- |this: &mut Self| this.settled_open = !this.settled_open,
1895- ),
1896- ] {
1897- if threads.is_empty() {
1898- continue;
1899- }
1900- children.push(self.render_fold(id, format!("{label} ({})", threads.len()), open, toggle, cx));
1901- if open {
1902- children.extend(threads.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1903- }
1904- }
1905-
1906- v_flex()
1907- .id("threads")
1908- .flex_1()
1909- .min_h_0()
1910- .overflow_y_scroll()
1911- .px(px(8.))
1912- .pt(px(12.))
1913- .pb(px(12.))
1914- .gap(px(3.))
1915- .when(is_empty, |this| {
1916- this.child(
1917- div()
1918- .px(px(12.))
1919- .py(px(8.))
1920- .text_size(px(12.5))
1921- .text_color(p.ink3)
1922- .child(if searching { "No threads match." } else { "No threads yet." }),
1923- )
1924- })
1925- .children(children)
1926- }
1927-
1928- fn render_sidebar_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
1929- let p = metal::palette(cx);
1930- let catalog = Catalog::global(cx);
1931- let claude = catalog.first_of(Provider::Claude);
1932- let account = claude.and_then(|state| state.account.clone());
1933- let checking = claude.is_some_and(|state| state.checking || state.checked.is_none());
1934- let workspace = cx.entity().downgrade();
1935-
1936- h_flex()
1937- .flex_none()
1938- .h(px(48.))
1939- .px(px(12.))
1940- .gap(px(8.))
1941- .child(
1942- MetalButton::new("settings")
1943- .ghost()
1944- .compact()
1945- .icon(IconName::Settings2)
1946- .label("Settings")
1947- .tooltip("Settings (Ctrl+,)")
1948- .on_click(move |_, window, cx| {
1949- let _ = workspace.update(cx, |this, cx| this.open_settings(window, cx));
1950- }),
1951- )
1952- .child(div().flex_1())
1953- .child(
1954- div()
1955- .min_w_0()
1956- .truncate()
1957- .font_family(metal::MONO)
1958- .text_size(px(10.5))
1959- .text_color(p.ink3)
1960- .child(account.clone().unwrap_or_else(|| if checking { "Connecting…" } else { "Not signed in" }.into())),
1961- )
1962- .child(metal::led("account-led", if account.is_some() { Led::Live } else { Led::Off }, false, p))
1963- }
1964-
1965- fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1966- v_flex()
1967- .w(px(SIDEBAR_WIDTH))
1968- .h_full()
1969- .flex_none()
1970- .child(self.render_sidebar_header(cx))
1971- .child(self.render_sidebar_toolbar(cx))
1972- .child(self.render_thread_list(cx))
1973- .child(self.render_sidebar_footer(cx))
1974- }
1975-
19761588 fn toggle_project_panel(&mut self, cx: &mut Context<Self>) {
19771589 // With the browser in its place, the project panel comes back.
19781590 if self.browser_open {
@@ -2016,92 +1628,6 @@ impl Workspace {
20161628 panel
20171629 }
20181630
2019- /// The browser button, lit when the open thread's browser runs while
2020- /// the panel is closed, as when the agent opens a page.
2021- fn render_browser_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2022- let p = metal::palette(cx);
2023- let live = !self.browser_open
2024- && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx));
2025- div()
2026- .relative()
2027- .child(
2028- MetalButton::new("toggle-browser")
2029- .ghost()
2030- .mini()
2031- .icon(IconName::Globe)
2032- .tooltip("Browser (Ctrl+Shift+E)")
2033- .on_click(cx.listener(|this, _, _, cx| {
2034- this.browser_open = !this.browser_open;
2035- cx.notify();
2036- })),
2037- )
2038- .when(live, |this| {
2039- this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p)))
2040- })
2041- }
2042-
2043- fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
2044- let p = metal::palette(cx);
2045- let project = self.active_project();
2046- let title = self.active.as_ref().map(|session| session.read(cx).title.clone());
2047-
2048- v_flex()
2049- .flex_none()
2050- .child(
2051- h_flex()
2052- .h(px(52.))
2053- .pl(px(if self.sidebar_open { 20. } else { 12. }))
2054- .pr(px(12.))
2055- .gap(px(10.))
2056- .when(!self.sidebar_open, |this| {
2057- this.child(
2058- MetalButton::new("show-sidebar")
2059- .ghost()
2060- .mini()
2061- .icon(Lucide::PanelLeftOpen)
2062- .tooltip("Show sidebar (Ctrl+B)")
2063- .on_click(cx.listener(|this, _, _, cx| {
2064- this.sidebar_open = true;
2065- cx.notify();
2066- })),
2067- )
2068- })
2069- .when_some(project, |this, project| {
2070- this.child(metal::badge(project.initials.clone(), project.tint, p)).child(
2071- div()
2072- .flex_none()
2073- .text_size(px(14.))
2074- .font_weight(FontWeight::MEDIUM)
2075- .text_color(p.ink2)
2076- .child(project.name.clone()),
2077- )
2078- })
2079- .when_some(title, |this, title| {
2080- this.child(div().text_size(px(14.)).text_color(p.ink3).child("/")).child(
2081- div()
2082- .flex_1()
2083- .min_w_0()
2084- .truncate()
2085- .text_size(px(14.))
2086- .font_weight(FontWeight::SEMIBOLD)
2087- .text_color(p.ink)
2088- .child(title),
2089- )
2090- })
2091- .child(div().flex_1())
2092- .child(self.render_browser_toggle(cx))
2093- .child(
2094- MetalButton::new("toggle-panel")
2095- .ghost()
2096- .mini()
2097- .icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen })
2098- .tooltip("Project panel (Ctrl+Shift+B)")
2099- .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))),
2100- ),
2101- )
2102- .child(metal::rule(p))
2103- }
2104-
21051631 /// One row of the project panel: an icon, a label and an optional trailer.
21061632 fn panel_row(icon: impl Into<Icon>, label: impl Into<SharedString>, p: &Palette) -> Div {
21071633 h_flex()
@@ -2433,12 +1959,53 @@ impl Render for Workspace {
24331959 .text_color(p.ink)
24341960 .font_family(cx.theme().font_family.clone())
24351961 .on_action(cx.listener(|this, _: &NewThread, window, cx| {
2436- if this.projects.is_empty() {
2437- this.prompt_for_project(window, cx);
1962+ // T3's chat.new: the project picker once there's a choice.
1963+ if this.legacy_sidebar() {
1964+ this.new_thread_button(true, window, cx);
24381965 } else {
2439- this.new_thread(this.active_project, window, cx);
1966+ this.new_thread_button(false, window, cx);
1967+ }
1968+ }))
1969+ .on_action(cx.listener(|this, _: &NewThreadLocal, window, cx| this.new_thread_button(true, window, cx)))
1970+ .on_action(cx.listener(|this, _: &NewThreadWithoutProject, window, cx| {
1971+ this.new_thread_without_project(window, cx)
1972+ }))
1973+ .on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| this.toggle_palette(window, cx)))
1974+ .on_action(cx.listener(|this, _: &PreviousThread, window, cx| this.step_thread(-1, window, cx)))
1975+ .on_action(cx.listener(|this, _: &NextThread, window, cx| this.step_thread(1, window, cx)))
1976+ .on_action(cx.listener(|this, _: &SettleThread, window, cx| {
1977+ if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
1978+ this.toggle_settled(session, window, cx);
1979+ }
1980+ }))
1981+ .on_action(cx.listener(|this, _: &PinThread, window, cx| {
1982+ if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
1983+ this.toggle_pin(session, window, cx);
1984+ }
1985+ }))
1986+ .on_action(cx.listener(|this, _: &UndoThreadAction, window, cx| {
1987+ if !this.undo_latest(window, cx) {
1988+ cx.propagate();
1989+ }
1990+ }))
1991+ .on_action(cx.listener(|this, _: &CopyThreadReference, window, cx| {
1992+ if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
1993+ Self::copy_thread_reference(&session, window, cx);
24401994 }
24411995 }))
1996+ .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx)))
1997+ .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| {
1998+ this.browser_open = !this.browser_open;
1999+ cx.notify();
2000+ }))
2001+ .on_action(cx.listener(|this, _: &SelectTheme, window, cx| {
2002+ this.open_palette(palette::PaletteView::Themes, window, cx)
2003+ }))
2004+ .on_action(cx.listener(|this, _: &CycleAppearance, window, cx| this.cycle_appearance(window, cx)))
2005+ .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.jump_with_key(event, window, cx)))
2006+ .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| {
2007+ this.modifiers_changed(event, window, cx)
2008+ }))
24422009 .on_action(cx.listener(|this, _: &OpenProject, window, cx| {
24432010 this.prompt_for_project(window, cx)
24442011 }))
@@ -2465,7 +2032,7 @@ impl Render for Workspace {
24652032 }
24662033 }))
24672034 .when(self.settings_open, |this| this.child(self.settings_view.clone()))
2468- .when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(cx)))
2035+ .when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(window, cx)))
24692036 .when(!self.settings_open, |this| this.child(
24702037 div()
24712038 .flex_1()
@@ -2494,6 +2061,37 @@ impl Render for Workspace {
24942061 ),
24952062 ),
24962063 ))
2064+ // The command palette floats over everything; a click beside it closes it.
2065+ .when_some(self.palette.as_ref().map(|(palette, _)| palette.clone()), |this, palette| {
2066+ this.child(
2067+ div()
2068+ .absolute()
2069+ .inset_0()
2070+ .flex()
2071+ .flex_col()
2072+ .items_center()
2073+ .pt(window.viewport_size().height * 0.12)
2074+ .px(px(16.))
2075+ .bg(black().opacity(if p.dark { 0.4 } else { 0.16 }))
2076+ .occlude()
2077+ .on_mouse_down(
2078+ MouseButton::Left,
2079+ cx.listener(|this, _, window, cx| this.close_palette(window, cx)),
2080+ )
2081+ .child(div().on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()).child(palette)),
2082+ )
2083+ })
2084+ .when_some(self.overlay_menu.as_ref(), |this, overlay| {
2085+ this.child(
2086+ deferred(
2087+ anchored()
2088+ .position(overlay.position)
2089+ .snap_to_window_with_margin(px(8.))
2090+ .child(overlay.menu.clone()),
2091+ )
2092+ .with_priority(2),
2093+ )
2094+ })
24972095 // The Add project browser floats over everything; a click beside it cancels.
24982096 .when_some(self.folder_browser.as_ref().map(|(browser, _)| browser.clone()), |this, browser| {
24992097 this.child(
+1184−0src/ui/workspace/actions.rsaddedView File

Large diffs are not rendered by default.

+253−0src/ui/workspace/header.rsaddedView File

Large diffs are not rendered by default.

+764−0src/ui/workspace/palette.rsaddedView File

Large diffs are not rendered by default.

+2−1src/ui/workspace/remote.rsView File

Large diffs are not rendered by default.

+1767−0src/ui/workspace/sidebar.rsaddedView File

Large diffs are not rendered by default.

+1015−0src/ui/workspace/sidebar/legacy.rsaddedView File

Large diffs are not rendered by default.