Vectrum
Sign In

Merge the T3 Code sidebar, thread menus, command palette and keymap

Brings in the inbox sidebar (Pinned, Active, Working, Snoozed and Settled
shelves, card and slim rows, multi-select, drag between shelves), the
legacy per-project sidebar, T3's thread action menu with every submenu and
confirmation, the custom snooze dialog, the chat header, the Ctrl+K command
palette and T3's default keymap.

On top of the merge: deleting a thread asks, as T3 Code does, whether to
delete its worktree too when no other thread uses it (it used to remove
it unasked, even with another thread in it), and a row's hover card no
longer covers the menu its right-click opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents f279824 ebdd857commit4154bf3

Showing 17 changed files with 6147 additions and 852 deletions.

Merge commit — showing changes against the first parent.

+46−11README.mdView File
@@ -25,10 +25,30 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
2525
2626 ## Features
2727
28- - A searchable thread list across all projects, newest activity first. Each card shows the
29- project, its git branch, how long ago it was active, the agent, and a status light.
28+ - A sidebar laid out like T3 Code's inbox: unsent drafts, pinned and active threads, then
29+ the **Working**, **Snoozed** and **Settled** shelves. Each card shows the project, its
30+ branch or worktree, running processes, the pull request, the agent, and a status
31+ (Working with a timer, Approval, Input, Limited, Failed, Woke, Done) or how long ago it
32+ was active; hovering swaps that for **Snooze** and **Settle**, and a hover card sums
33+ the thread up. Search covers titles and conversations (↑ ↓ Enter, Esc clears), and the
34+ folder button filters the sidebar to one project. Drag the sidebar's edge to resize it.
3035 - Settle a thread to stop its process and file it under **Settled**. Its transcript stays,
31- and the next message resumes the conversation.
36+ and the next message resumes the conversation. Settling, snoozing, unpinning, archiving
37+ and discarding a draft leave a "Settled 1 thread, Ctrl+Z to undo" notice for 5 seconds.
38+ - T3 Code's thread menu, on a right click in the sidebar or a click on the title in the
39+ chat header: New thread on *branch*, Pin, Settle, Snooze (in 1 or 3 hours, this
40+ evening, tomorrow, next week, or a custom date, time or duration), Wake, Rename,
41+ Regenerate title, Mark unread, Filter by *project*, Auto-settle behavior, Copy (path,
42+ branch, thread ID), Project settings, Archive and Delete. Ctrl+click or Shift+click picks
43+ several threads for one menu; a double-click renames in place.
44+ - A command palette (Ctrl+K) with T3 Code's actions (new thread in a project or without
45+ one, copy the thread ID or PR link, restart the agent, add a project, change the theme or
46+ appearance, usage, settings) and recent threads; typing also finds projects, settings
47+ and threads, and a leading `>` searches actions only.
48+ - The legacy sidebar (Settings → *Legacy sidebar*): threads in per-project trees, with
49+ T3's sort menu (projects by last message, creation or by hand, threads by last message
50+ or creation, how many show) and project menu (Rename, Group into..., Copy Path, Project
51+ settings, Remove).
3252 - A new thread opens on a start screen, "What should we build in *project*?", with the
3353 project as a picker (No project, the projects, Add project) and a way to start without a
3454 project ("What should we work on?").
@@ -106,9 +126,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
106126 branch naming: a prefix plus a few words, a semantic prefix like `feat/`, or your own
107127 instructions). Ctrl+Shift+L starts in the project's previous worktree instead.
108128 - Thread titles written by a small model from the first message (Settings → Text generation).
109- - Pin threads to the top, snooze them until later, rename them (right-click a thread card).
110- The sidebar can group threads by project, fold working threads into a Working section, and
111- order projects by last message, name or date added.
129+ - Pin threads to the top, snooze them until later, rename them, archive them to Settings →
130+ Archive. Working threads can fold into a Working shelf until they need you.
112131 - When a usage limit stops a thread, it can snooze until the limit resets and continue then.
113132 Idle threads, and worktree threads whose pull request merged, can settle on their own.
114133 - Send while the agent works to steer it, or queue the message for after the turn (and
@@ -163,21 +182,35 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
163182 cargo run --release -- /path/to/project # defaults to the current directory
164183 ```
165184
185+ The shortcuts follow T3 Code's default keymap.
186+
166187 | Shortcut | Action |
167188 | --- | --- |
168189 | Enter / Shift+Enter | Send / new line |
169190 | Esc | Interrupt Claude |
191+ | Ctrl+K | Command palette |
192+ | Ctrl+N (or Ctrl+Shift+O) | New thread (picks the project when there are several) |
193+ | Ctrl+Shift+N | New thread in the current project |
194+ | Ctrl+Alt+N | New thread without a project |
195+ | Ctrl+Shift+[ / Ctrl+Shift+] | Previous / next thread |
196+ | Ctrl+1–9 | Jump to a sidebar thread (hold Ctrl to see the numbers) |
197+ | Ctrl+Shift+S | Settle the thread, or un-settle it |
198+ | Ctrl+Shift+P | Pin or unpin the thread |
199+ | Ctrl+Z | Undo the last settle, snooze, unpin or archive (while the notice shows) |
200+ | Ctrl+Shift+C | Copy the thread's PR link, or its ID |
201+ | Ctrl+U | Usage |
202+ | Ctrl+Alt+A / Ctrl+Alt+Shift+A | Change the theme / cycle System, Light and Dark |
170203 | Ctrl+Shift+M / E / A | Model picker / reasoning / access |
171204 | Ctrl+Shift+G / X / H | Branch / workspace / machine (new thread) |
172205 | Ctrl+Shift+L | Start in the previous worktree |
173206 | Ctrl+S | Stash the prompt, or open the stash |
174207 | Alt+↑ | Edit the last queued message |
175- | Ctrl+N | New thread in the current project |
208+ | Ctrl+F | Find in thread |
209+ | Ctrl+B | Show or hide the sidebar |
210+ | Ctrl+Alt+B | Show or hide the right panel (the browser) |
211+ | Ctrl+Shift+B | Show or hide the thread details (project panel) |
176212 | Ctrl+O | Add a project folder |
177- | Ctrl+K | Search threads |
178213 | Ctrl+, | Open or close settings |
179- | Ctrl+B | Show or hide the sidebar |
180- | Ctrl+Shift+B | Show or hide the project panel |
181214 | Ctrl+Q | Quit |
182215
183216 ### Environment
@@ -211,7 +244,9 @@ in its `scratch` folder.
211244 - `src/ui/settings.rs`: the settings view and its pages.
212245 - `src/ui/changes.rs`: the per-turn change overview and the diff view.
213246 - `src/ui/workspace.rs`: window root: thread sidebar, top bar and project panel, plus the
214- git and editor helpers behind it.
247+ git and editor helpers behind it. Its submodules hold the sidebar (`sidebar.rs`, and the
248+ legacy layout in `sidebar/legacy.rs`), the thread menus, snoozing, confirmations and undo
249+ (`actions.rs`), the chat header (`header.rs`) and the command palette (`palette.rs`).
215250 - `src/ui/icons.rs`: the extra Lucide icons the app embeds.
216251 - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from
217252 MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells,
+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
@@ -530,6 +530,19 @@ pub struct Session {
530530 pub pinned: bool,
531531 /// Hidden from the thread list until then.
532532 pub snoozed_until: Option<SystemTime>,
533+ /// When the thread was settled, for the Settled shelf's "settled 2h ago".
534+ pub settled_at: Option<SystemTime>,
535+ /// Something happened the user hasn't seen: a turn finished while the
536+ /// thread wasn't open, or they marked it unread.
537+ pub unread: bool,
538+ /// Woke from a snooze and the user hasn't looked yet.
539+ pub woke: bool,
540+ /// Whether the app may settle the thread on its own (Auto-settle behavior).
541+ pub auto_settle: bool,
542+ /// Taken out of the sidebar; kept under Settings → Archive.
543+ pub archived: bool,
544+ /// A title is being regenerated.
545+ pub regenerating_title: bool,
533546 /// When a usage limit that stopped the thread resets.
534547 pub limit_resets: Option<SystemTime>,
535548 /// When to continue the thread on its own, after a usage limit.
@@ -647,6 +660,12 @@ impl Session {
647660 settled: false,
648661 pinned: false,
649662 snoozed_until: None,
663+ settled_at: None,
664+ unread: false,
665+ woke: false,
666+ auto_settle: true,
667+ archived: false,
668+ regenerating_title: false,
650669 limit_resets: None,
651670 resume_at: None,
652671 queue: Vec::new(),
@@ -859,7 +878,9 @@ impl Session {
859878 self.last_message = now;
860879 self.updated = now;
861880 self.settled = false;
881+ self.settled_at = None;
862882 self.snoozed_until = None;
883+ self.woke = false;
863884 self.resume_at = None;
864885 self.interrupted = false;
865886 if first {
@@ -1243,6 +1264,30 @@ impl Session {
12431264 .detach();
12441265 }
12451266
1267+ /// Writes a new title from the first message with the text generation
1268+ /// model, replacing a custom one.
1269+ pub fn regenerate_title(&mut self, cx: &mut Context<Self>) {
1270+ let Some(message) = self.first_user_text() else { return };
1271+ if self.regenerating_title {
1272+ return;
1273+ }
1274+ self.regenerating_title = true;
1275+ cx.notify();
1276+ let task = cx.background_executor().spawn(async move { crate::textgen::title(&message) });
1277+ cx.spawn(async move |this, cx| {
1278+ let title = task.await;
1279+ let _ = this.update(cx, |this, cx| {
1280+ this.regenerating_title = false;
1281+ if let Some(title) = title {
1282+ this.title = title.into();
1283+ this.title_custom = false;
1284+ }
1285+ cx.notify();
1286+ });
1287+ })
1288+ .detach();
1289+ }
1290+
12461291 /// The branch a new worktree starts from.
12471292 pub fn set_worktree_base(&mut self, base: String, cx: &mut Context<Self>) {
12481293 if let Some(plan) = self.pending_worktree.as_mut() {
@@ -1251,6 +1296,23 @@ impl Session {
12511296 }
12521297 }
12531298
1299+ /// The text of the first message the user sent.
1300+ pub fn first_user_text(&self) -> Option<String> {
1301+ self.items.iter().find_map(|item| match item {
1302+ ChatItem::User { text, attachments, .. } if !text.trim().is_empty() || !attachments.is_empty() => {
1303+ Some(if text.trim().is_empty() { attachments.join(", ") } else { text.to_string() })
1304+ }
1305+ _ => None,
1306+ })
1307+ }
1308+
1309+ pub fn set_unread(&mut self, unread: bool, cx: &mut Context<Self>) {
1310+ if self.unread != unread {
1311+ self.unread = unread;
1312+ cx.notify();
1313+ }
1314+ }
1315+
12541316 /// Whether a new worktree starts from origin's copy of its branch.
12551317 pub fn set_worktree_from_origin(&mut self, on: bool, cx: &mut Context<Self>) {
12561318 if let Some(plan) = self.pending_worktree.as_mut() {
@@ -1259,6 +1321,15 @@ impl Session {
12591321 }
12601322 }
12611323
1324+ /// Acknowledges a thread the user opened: it's read, and no longer woke.
1325+ pub fn mark_seen(&mut self, cx: &mut Context<Self>) {
1326+ if self.unread || self.woke {
1327+ self.unread = false;
1328+ self.woke = false;
1329+ cx.notify();
1330+ }
1331+ }
1332+
12621333 /// Points the (still empty) thread at a worktree that already exists,
12631334 /// such as the one a previous thread worked in, or back at the project
12641335 /// folder with `None`.
@@ -1285,6 +1356,34 @@ impl Session {
12851356 cx.notify();
12861357 }
12871358
1359+ pub fn dismiss_woke(&mut self, cx: &mut Context<Self>) {
1360+ if self.woke {
1361+ self.woke = false;
1362+ cx.notify();
1363+ }
1364+ }
1365+
1366+ pub fn set_auto_settle(&mut self, on: bool, cx: &mut Context<Self>) {
1367+ self.auto_settle = on;
1368+ cx.notify();
1369+ }
1370+
1371+ /// Takes the thread out of the sidebar, or brings it back.
1372+ pub fn set_archived(&mut self, archived: bool, cx: &mut Context<Self>) {
1373+ self.archived = archived;
1374+ if archived {
1375+ self.pinned = false;
1376+ }
1377+ cx.notify();
1378+ }
1379+
1380+ /// Ends a snooze because its time came, so the sidebar says it woke.
1381+ pub fn wake(&mut self, cx: &mut Context<Self>) {
1382+ self.snooze(None, cx);
1383+ self.woke = true;
1384+ cx.notify();
1385+ }
1386+
12881387 pub fn rename(&mut self, title: String, cx: &mut Context<Self>) {
12891388 self.forward(ThreadAction::Rename { title: title.trim().to_string() });
12901389 let title = title.trim();
@@ -1326,6 +1425,39 @@ impl Session {
13261425 cx.notify();
13271426 }
13281427
1428+ /// Stops the agent process; the next message starts a fresh one that
1429+ /// resumes the conversation. Does nothing mid-turn.
1430+ pub fn restart_agent(&mut self, cx: &mut Context<Self>) -> bool {
1431+ if self.remote.is_some() || self.is_working() || self.process.is_none() {
1432+ return false;
1433+ }
1434+ self.process.take();
1435+ self._events_task = Task::ready(());
1436+ self.pending_controls.clear();
1437+ self.drop_background_tasks();
1438+ if self.status != SessionStatus::Exited {
1439+ self.status = SessionStatus::Idle;
1440+ }
1441+ cx.notify();
1442+ true
1443+ }
1444+
1445+ /// Moves a fresh thread into another thread's worktree, as "New thread on
1446+ /// {branch}" does for a worktree thread, restarting its agent there.
1447+ pub fn adopt_worktree(&mut self, worktree: Worktree, cwd: PathBuf, cx: &mut Context<Self>) {
1448+ if !self.items.is_empty() || self.remote.is_some() {
1449+ return;
1450+ }
1451+ self.process.take();
1452+ self._events_task = Task::ready(());
1453+ self.pending_controls.clear();
1454+ self.pending_worktree = None;
1455+ self.cwd = cwd;
1456+ self.worktree = Some(worktree);
1457+ self.start(None, cx);
1458+ cx.notify();
1459+ }
1460+
13291461 pub fn set_pinned(&mut self, pinned: bool, cx: &mut Context<Self>) {
13301462 self.forward(ThreadAction::Pin { pinned });
13311463 self.pinned = pinned;
@@ -1337,6 +1469,8 @@ impl Session {
13371469 self.snoozed_until = until;
13381470 if until.is_none() {
13391471 self.updated = SystemTime::now();
1472+ } else {
1473+ self.woke = false;
13401474 }
13411475 cx.notify();
13421476 }
@@ -1371,6 +1505,10 @@ impl Session {
13711505 /// Files the thread under Settled and stops its process. The transcript
13721506 /// stays, and the next message resumes the conversation.
13731507 pub fn settle(&mut self, cx: &mut Context<Self>) {
1508+ // Settling clears the pin: done beats keep on top.
1509+ self.pinned = false;
1510+ self.settled_at = Some(SystemTime::now());
1511+ self.woke = false;
13741512 if self.forward(ThreadAction::Settle) {
13751513 self.settled = true;
13761514 cx.notify();
@@ -1401,6 +1539,7 @@ impl Session {
14011539 pub fn unsettle(&mut self, cx: &mut Context<Self>) {
14021540 self.forward(ThreadAction::Unsettle);
14031541 self.settled = false;
1542+ self.settled_at = None;
14041543 self.updated = SystemTime::now();
14051544 cx.notify();
14061545 }
@@ -3001,6 +3140,16 @@ pub struct SavedThread {
30013140 pub settled: bool,
30023141 pub pinned: bool,
30033142 pub snoozed_until: Option<u64>,
3143+ #[serde(default)]
3144+ pub settled_at: Option<u64>,
3145+ #[serde(default)]
3146+ pub unread: bool,
3147+ #[serde(default)]
3148+ pub woke: bool,
3149+ #[serde(default = "default_true")]
3150+ pub auto_settle: bool,
3151+ #[serde(default)]
3152+ pub archived: bool,
30043153 pub limit_resets: Option<u64>,
30053154 pub resume_at: Option<u64>,
30063155 pub queue: Vec<QueuedMessage>,
@@ -3052,6 +3201,10 @@ pub enum SavedItem {
30523201 TurnEnd { duration_ms: u64, cost: Option<f64>, at: u64 },
30533202 }
30543203
3204+ fn default_true() -> bool {
3205+ true
3206+ }
3207+
30553208 pub fn to_millis(time: SystemTime) -> u64 {
30563209 time.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
30573210 }
@@ -3159,6 +3312,11 @@ impl Session {
31593312 settled: self.settled,
31603313 pinned: self.pinned,
31613314 snoozed_until: self.snoozed_until.map(to_millis),
3315+ settled_at: self.settled_at.map(to_millis),
3316+ unread: self.unread,
3317+ woke: self.woke,
3318+ auto_settle: self.auto_settle,
3319+ archived: self.archived,
31623320 limit_resets: self.limit_resets.map(to_millis),
31633321 resume_at: self.resume_at.map(to_millis),
31643322 queue: self.queue.clone(),
@@ -3199,6 +3357,11 @@ impl Session {
31993357 session.settled = saved.settled;
32003358 session.pinned = saved.pinned;
32013359 session.snoozed_until = saved.snoozed_until.map(from_millis);
3360+ session.settled_at = saved.settled_at.map(from_millis);
3361+ session.unread = saved.unread;
3362+ session.woke = saved.woke;
3363+ session.auto_settle = saved.auto_settle;
3364+ session.archived = saved.archived;
32023365 session.limit_resets = saved.limit_resets.map(from_millis);
32033366 session.resume_at = saved.resume_at.map(from_millis);
32043367 session.queue = saved.queue;
+68−1src/settings.rsView File
@@ -61,6 +61,54 @@ pub enum ProjectOrder {
6161 LastMessage,
6262 Name,
6363 Added,
64+ /// The order projects were added in, as the legacy sidebar's Manual sort.
65+ Manual,
66+ }
67+
68+ /// Which projects share a row in the legacy sidebar.
69+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
70+ #[serde(rename_all = "snake_case")]
71+ pub enum ProjectGrouping {
72+ /// Projects from the same repository share one row.
73+ #[default]
74+ Repository,
75+ /// Projects group only when both the repository and the path inside it match.
76+ RepositoryPath,
77+ /// Every project path gets its own row.
78+ Separate,
79+ }
80+
81+ impl ProjectGrouping {
82+ pub const ALL: [ProjectGrouping; 3] =
83+ [ProjectGrouping::Repository, ProjectGrouping::RepositoryPath, ProjectGrouping::Separate];
84+
85+ pub fn label(self) -> &'static str {
86+ match self {
87+ ProjectGrouping::Repository => "Group by repository",
88+ ProjectGrouping::RepositoryPath => "Group by repository path",
89+ ProjectGrouping::Separate => "Keep separate",
90+ }
91+ }
92+
93+ pub fn description(self) -> &'static str {
94+ match self {
95+ ProjectGrouping::Repository => "Projects from the same repository share one sidebar row.",
96+ ProjectGrouping::RepositoryPath => {
97+ "Projects group only when both the repository and repo-relative path match."
98+ }
99+ ProjectGrouping::Separate => "Every project path gets its own sidebar row.",
100+ }
101+ }
102+ }
103+
104+ /// How the legacy sidebar orders the threads under each project.
105+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
106+ #[serde(rename_all = "snake_case")]
107+ pub enum ThreadOrder {
108+ /// The thread with the most recent message first.
109+ #[default]
110+ LastMessage,
111+ Created,
64112 }
65113
66114 /// Which of the two chosen themes shows.
@@ -277,9 +325,21 @@ pub struct Settings {
277325 pub diff_side_by_side: bool,
278326
279327 // ---- sidebar ----
328+ /// T3 Code's legacy sidebar: threads in per-project trees instead of the
329+ /// default flat inbox.
330+ pub legacy_sidebar: bool,
331+ /// How the legacy sidebar orders threads under a project.
332+ pub thread_order: ThreadOrder,
333+ /// How many threads the legacy sidebar shows per project before "Show more".
334+ pub visible_threads: u32,
335+ /// Which projects share a row in the legacy sidebar.
336+ pub project_grouping: ProjectGrouping,
337+ /// Per-project grouping rules, by project path, overriding the default.
338+ pub project_grouping_overrides: std::collections::BTreeMap<String, ProjectGrouping>,
280339 /// One-line thread cards in the sidebar.
281340 pub compact_threads: bool,
282- /// Group sidebar threads under their project.
341+ /// Group sidebar threads under their project: turns on the legacy
342+ /// sidebar, like `legacy_sidebar`.
283343 pub group_by_project: bool,
284344 pub project_order: ProjectOrder,
285345 /// Fold working threads into their own section until they need you.
@@ -295,6 +355,7 @@ pub struct Settings {
295355
296356 // ---- confirmations ----
297357 pub confirm_unpin: bool,
358+ pub confirm_archive: bool,
298359 /// Settling from a thread card takes a second click.
299360 pub confirm_settle: bool,
300361 pub confirm_delete: bool,
@@ -378,6 +439,11 @@ impl Default for Settings {
378439 hide_whitespace: true,
379440 diff_collapsed: false,
380441 diff_side_by_side: false,
442+ legacy_sidebar: false,
443+ thread_order: ThreadOrder::LastMessage,
444+ visible_threads: 6,
445+ project_grouping: ProjectGrouping::Repository,
446+ project_grouping_overrides: std::collections::BTreeMap::new(),
381447 compact_threads: false,
382448 group_by_project: false,
383449 project_order: ProjectOrder::LastMessage,
@@ -388,6 +454,7 @@ impl Default for Settings {
388454 auto_settle_inactive: false,
389455 inactive_days: 3,
390456 confirm_unpin: false,
457+ confirm_archive: false,
391458 confirm_settle: false,
392459 confirm_delete: true,
393460 quit_shortcut: QuitShortcut::Hold,
+10−0src/store.rsView File
@@ -24,6 +24,16 @@ 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>,
35+ /// The thread sidebar's width as last dragged; `None` is the default.
36+ pub sidebar_width: Option<f32>,
2737 }
2838
2939 fn threads_dir() -> Option<PathBuf> {
+11−0src/ui/chat.rsView File
@@ -421,6 +421,17 @@ impl ChatView {
421421 self.session.update(cx, |session, cx| session.ask_set_custom(ix, text, cx));
422422 }
423423
424+ /// What's typed and attached but not sent, for the sidebar's draft rows.
425+ pub fn draft(&self, cx: &App) -> (String, usize) {
426+ (self.composer.read(cx).value().to_string(), self.attachments.len())
427+ }
428+
429+ /// Empties the composer, as discarding an unsent draft does.
430+ pub fn clear_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
431+ self.attachments.clear();
432+ self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
433+ }
434+
424435 /// Sends what's in the composer. While the agent works, the follow-up
425436 /// setting decides between steering it now and queueing for after the
426437 /// turn; `opposite` flips that for this one message.
+21−0src/ui/icons.rsView File
@@ -83,6 +83,27 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
8383 Wrench,
8484 Quote,
8585 Download,
86+ Clock,
87+ CircleDashed,
88+ ShieldQuestionMark,
89+ Timer,
90+ FolderTree,
91+ Hash,
92+ MailOpen,
93+ MessageSquarePlus,
94+ ChartNoAxesColumn,
95+ SquareMenu,
96+ Maximize2,
97+ MessageSquare,
98+ Link,
99+ ArrowUpDown,
100+ X,
101+ ArrowRightLeft,
102+ GitPullRequestArrow,
103+ GitMerge,
104+ GitPullRequestClosed,
105+ GitPullRequestDraft,
106+ ListFilter,
86107 ]);
87108
88109 /// 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));
+496−836src/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,27 +26,39 @@ 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, Worktree, 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.;
58+ /// The narrowest the thread sidebar drags to, and how much room it leaves
59+ /// the conversation.
60+ const SIDEBAR_MIN_WIDTH: f32 = 208.;
61+ const MAIN_MIN_WIDTH: f32 = 640.;
5162 const PANEL_WIDTH: f32 = 292.;
5263 /// Below this window width the project panel stays out of the conversation's way.
5364 const PANEL_MIN_WINDOW: f32 = 1120.;
@@ -55,6 +66,8 @@ const PANEL_MIN_WINDOW: f32 = 1120.;
5566 pub(crate) const SHEET_INSET: f32 = 8.;
5667 pub(crate) const SHEET_RADIUS: f32 = 24.;
5768 const TICK: Duration = Duration::from_secs(2);
69+ /// How long Ctrl is held before the Ctrl+1–9 hints show.
70+ const JUMP_HINT_DELAY: Duration = Duration::from_millis(200);
5871 const GIT_POLL: Duration = Duration::from_secs(4);
5972 const HOUSEKEEPING: Duration = Duration::from_secs(15);
6073 const PR_CHECK: Duration = Duration::from_secs(300);
@@ -126,11 +139,35 @@ struct Project {
126139 git: Option<GitInfo>,
127140 /// The remote connection it's on, by id; `None` on this device.
128141 environment: Option<String>,
142+ /// The repository it's in (its common directory) and its path inside
143+ /// the checkout, for grouping projects in the legacy sidebar.
144+ repo: Option<(PathBuf, PathBuf)>,
129145 }
130146
131147 /// A thread in the sidebar: its project's index and the session.
132148 type ThreadEntry = (usize, Entity<Session>);
133149
150+ /// Names a project across changes to the list: its path, and the remote
151+ /// connection it's on (`None` for this device).
152+ pub(crate) type ProjectKey = (PathBuf, Option<String>);
153+
154+ /// A thread title being edited in place, in the sidebar or the chat header.
155+ struct InlineRename {
156+ session: Entity<Session>,
157+ input: Entity<InputState>,
158+ /// In the sidebar row, rather than the chat header.
159+ sidebar: bool,
160+ _subscription: Subscription,
161+ }
162+
163+ /// A menu opened from code rather than a right click: the chat header's
164+ /// thread menu, the project scope picker's settings, and the like.
165+ struct OverlayMenu {
166+ menu: Entity<PopupMenu>,
167+ position: Point<Pixels>,
168+ _dismiss: Subscription,
169+ }
170+
134171 /// A settled thread, as the settings' archive lists it.
135172 pub struct SettledThread {
136173 pub session: Entity<Session>,
@@ -156,6 +193,8 @@ struct Activity {
156193 }
157194
158195 pub struct Workspace {
196+ /// The row whose hover card stays hidden while its menu is open.
197+ hover_card_hidden: Option<EntityId>,
159198 projects: Vec<Project>,
160199 chats: HashMap<EntityId, Entity<ChatView>>,
161200 /// Each thread's browser panel, made the first time it's opened.
@@ -172,8 +211,38 @@ pub struct Workspace {
172211 settled_open: bool,
173212 snoozed_open: bool,
174213 working_open: bool,
175- /// Projects folded away in the grouped thread list.
214+ /// How many settled threads the Settled shelf lists before "Show N more".
215+ settled_shown: usize,
216+ /// Projects folded away in the legacy sidebar.
176217 collapsed_projects: HashSet<PathBuf>,
218+ /// Projects whose full thread list the legacy sidebar shows ("Show more").
219+ expanded_projects: HashSet<PathBuf>,
220+ /// The project the sidebar is filtered to; `None` shows all projects.
221+ project_scope: Option<ProjectKey>,
222+ /// The project scope picker, while open, with its search field.
223+ scope_picker: Option<(Entity<InputState>, Subscription)>,
224+ /// Threads picked with Ctrl+click or Shift+click, in the order picked.
225+ selection: Vec<EntityId>,
226+ /// Where a Shift+click range starts.
227+ selection_anchor: Option<EntityId>,
228+ /// The highlighted row of the sidebar's search results.
229+ search_highlight: usize,
230+ renaming: Option<InlineRename>,
231+ overlay_menu: Option<OverlayMenu>,
232+ /// The command palette, while open.
233+ palette: Option<(Entity<CommandPalette>, Subscription)>,
234+ /// Actions the undo notice can take back, oldest first.
235+ undo: Vec<UndoEntry>,
236+ undo_generation: u64,
237+ undo_timer: Task<()>,
238+ /// When Ctrl went down, for showing the Ctrl+1–9 jump hints after a beat.
239+ ctrl_held: Option<Instant>,
240+ jump_hints: bool,
241+ /// A title click waiting to see whether it becomes a double-click.
242+ title_click: Task<()>,
243+ /// The thread sidebar's width, and whether its edge is being dragged.
244+ sidebar_width: f32,
245+ resizing_sidebar: bool,
177246 editors: Rc<Vec<EditorApp>>,
178247 editor: usize,
179248 hostname: SharedString,
@@ -184,6 +253,10 @@ pub struct Workspace {
184253 activity: HashMap<EntityId, Activity>,
185254 /// The thread whose settle button waits for its confirming click.
186255 settle_armed: Option<(EntityId, Instant)>,
256+ /// The legacy row whose Archive button waits for its Confirm click.
257+ archive_armed: Option<(EntityId, Instant)>,
258+ /// Where the chat header's title sits, to open its menu under it.
259+ title_bounds: Rc<std::cell::Cell<Bounds<Pixels>>>,
187260 /// Threads changed since they were last saved.
188261 dirty: HashSet<EntityId>,
189262 /// `state.json` as last written.
@@ -235,10 +308,13 @@ impl Workspace {
235308 cx.notify();
236309 }
237310 }),
238- cx.subscribe(&search, |_, _, event: &InputEvent, cx| {
239- if matches!(event, InputEvent::Change) {
311+ cx.subscribe_in(&search, window, |this, _, event: &InputEvent, window, cx| match event {
312+ InputEvent::Change => {
313+ this.search_highlight = 0;
240314 cx.notify();
241315 }
316+ InputEvent::PressEnter { .. } => this.open_search_result(window, cx),
317+ _ => {}
242318 }),
243319 ];
244320 let ticker = cx.spawn_in(window, async move |this, cx| {
@@ -264,6 +340,7 @@ impl Workspace {
264340 let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
265341 let now = Instant::now();
266342 let mut workspace = Self {
343+ hover_card_hidden: None,
267344 projects: Vec::new(),
268345 chats: HashMap::new(),
269346 browsers: HashMap::new(),
@@ -277,7 +354,25 @@ impl Workspace {
277354 settled_open: false,
278355 snoozed_open: false,
279356 working_open: false,
357+ settled_shown: sidebar::SETTLED_PAGE,
280358 collapsed_projects: HashSet::new(),
359+ expanded_projects: HashSet::new(),
360+ project_scope: None,
361+ scope_picker: None,
362+ selection: Vec::new(),
363+ selection_anchor: None,
364+ search_highlight: 0,
365+ renaming: None,
366+ overlay_menu: None,
367+ palette: None,
368+ undo: Vec::new(),
369+ undo_generation: 0,
370+ undo_timer: Task::ready(()),
371+ ctrl_held: None,
372+ jump_hints: false,
373+ title_click: Task::ready(()),
374+ sidebar_width: SIDEBAR_WIDTH,
375+ resizing_sidebar: false,
281376 editors: Rc::new(editors),
282377 editor,
283378 hostname: hostname().into(),
@@ -286,6 +381,8 @@ impl Workspace {
286381 folder_browser: None,
287382 activity: HashMap::new(),
288383 settle_armed: None,
384+ archive_armed: None,
385+ title_bounds: Rc::default(),
289386 dirty: HashSet::new(),
290387 saved_state: String::new(),
291388 worktree_git: HashMap::new(),
@@ -329,6 +426,11 @@ impl Workspace {
329426 /// open.
330427 fn restore(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<ThreadEntry> {
331428 let state = store::load_state();
429+ self.working_open = state.working_expanded;
430+ self.snoozed_open = state.snoozed_expanded;
431+ self.settled_open = state.settled_expanded;
432+ self.collapsed_projects = state.collapsed_projects.iter().cloned().collect();
433+ self.sidebar_width = state.sidebar_width.unwrap_or(SIDEBAR_WIDTH).max(SIDEBAR_MIN_WIDTH);
332434 for project in &state.projects {
333435 let name = Some(project.name.as_str()).filter(|name| !name.is_empty());
334436 let ix = self.ensure_project(project.path.clone(), name);
@@ -352,6 +454,10 @@ impl Workspace {
352454 session.update(cx, |session, cx| session.send_message(CONTINUE_AFTER_RESTART.into(), Vec::new(), cx));
353455 }
354456 }
457+ self.project_scope = state
458+ .project_scope
459+ .filter(|path| self.projects.iter().any(|project| project.environment.is_none() && &project.path == path))
460+ .map(|path| (path, None));
355461 self.saved_state = serde_json::to_string(&self.app_state(cx)).unwrap_or_default();
356462 active
357463 }
@@ -370,6 +476,16 @@ impl Workspace {
370476 })
371477 .collect(),
372478 active: self.active.as_ref().map(|session| session.read(cx).id.clone()),
479+ project_scope: self.project_scope.as_ref().filter(|(_, env)| env.is_none()).map(|(path, _)| path.clone()),
480+ working_expanded: self.working_open,
481+ snoozed_expanded: self.snoozed_open,
482+ settled_expanded: self.settled_open,
483+ collapsed_projects: {
484+ let mut paths: Vec<PathBuf> = self.collapsed_projects.iter().cloned().collect();
485+ paths.sort();
486+ paths
487+ },
488+ sidebar_width: Some(self.sidebar_width.round()).filter(|width| *width != SIDEBAR_WIDTH),
373489 }
374490 }
375491
@@ -446,6 +562,17 @@ impl Workspace {
446562 self.settle_armed = None;
447563 cx.notify();
448564 }
565+ if let Some((_, armed)) = self.archive_armed
566+ && armed.elapsed() >= SETTLE_ARMED
567+ {
568+ self.archive_armed = None;
569+ cx.notify();
570+ }
571+ // Working threads show how long they've worked.
572+ let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working());
573+ if working && self.sidebar_open {
574+ cx.notify();
575+ }
449576 // Repaint about once a minute so thread ages stay current.
450577 if self.last_repaint.elapsed() >= Duration::from_secs(60) {
451578 self.last_repaint = Instant::now();
@@ -526,14 +653,16 @@ impl Workspace {
526653 state.resume_at.is_some_and(|at| at <= now) && !state.is_working(),
527654 settle_inactive
528655 && idle
656+ && state.auto_settle
529657 && !state.settled
658+ && !state.archived
530659 && !state.pinned
531660 && state.resume_at.is_none()
532661 && now.duration_since(state.updated).unwrap_or_default() >= inactive,
533662 )
534663 };
535664 if wake {
536- session.update(cx, |session, cx| session.snooze(None, cx));
665+ session.update(cx, |session, cx| session.wake(cx));
537666 }
538667 if resume {
539668 session.update(cx, |session, cx| {
@@ -580,6 +709,7 @@ impl Workspace {
580709 let _ = session.update(cx, |session, cx| {
581710 let was_open = session.pull_request.as_ref().is_none_or(|before| before.is_open());
582711 let settle = was_open
712+ && session.auto_settle
583713 && !session.is_working()
584714 && match pull_request.state.as_str() {
585715 "MERGED" => merged_settles,
@@ -810,6 +940,11 @@ impl Workspace {
810940 return;
811941 }
812942 let failed = state.ended_with_error();
943+ let is_open = self.active.as_ref() == Some(&session) && !self.settings_open && window.is_window_active();
944+ if before.working && !now.working && !is_open && !state.unread {
945+ session.update(cx, |session, cx| session.set_unread(true, cx));
946+ }
947+ let state = session.read(cx);
813948 let event = if now.attention && !before.attention {
814949 "Waiting for you"
815950 } else if before.working && !now.working {
@@ -925,7 +1060,9 @@ impl Workspace {
9251060 .unwrap_or_else(|| path.display().to_string())
9261061 });
9271062 let git = git::read_info(&path);
1063+ let repo = git.as_ref().and_then(|_| git::repository_of(&path));
9281064 self.projects.push(Project {
1065+ repo,
9291066 path,
9301067 initials: initials(&name).into(),
9311068 tint: tint_for(&name),
@@ -944,6 +1081,7 @@ impl Workspace {
9441081 match settings::get(|settings| settings.project_order) {
9451082 ProjectOrder::Name => order.sort_by_key(|&ix| self.projects[ix].name.to_lowercase()),
9461083 ProjectOrder::Added => order.sort_by_key(|&ix| std::cmp::Reverse(self.projects[ix].added)),
1084+ ProjectOrder::Manual => {}
9471085 ProjectOrder::LastMessage => order.sort_by_key(|&ix| {
9481086 let project = &self.projects[ix];
9491087 let last = project
@@ -967,138 +1105,237 @@ impl Workspace {
9671105 cx: &mut Context<Self>,
9681106 ) {
9691107 self.active_project = project_ix;
970- session.update(cx, |session, _| session.subscribe_remote());
1108+ session.update(cx, |session, cx| {
1109+ session.subscribe_remote();
1110+ session.mark_seen(cx);
1111+ });
9711112 if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
9721113 chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
9731114 }
974- self.active = Some(session);
1115+ // Opening a thread drops the multi-select, as in T3 Code.
1116+ self.selection.clear();
1117+ let previous = self.active.replace(session.clone());
1118+ if let Some(previous) = previous.filter(|previous| previous != &session) {
1119+ self.prune_empty_draft(&previous, cx);
1120+ }
1121+ if self.renaming.as_ref().is_some_and(|rename| !rename.sidebar) {
1122+ self.renaming = None;
1123+ }
9751124 self.last_git = Instant::now() - GIT_POLL;
9761125 cx.notify();
9771126 }
9781127
979- fn settle_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
980- self.settle_armed = None;
981- session.update(cx, |session, cx| session.settle(cx));
982- cx.notify();
1128+ /// Forgets a new thread left without anything sent or typed, as T3 drops
1129+ /// an empty draft once you move on.
1130+ fn prune_empty_draft(&mut self, session: &Entity<Session>, cx: &mut Context<Self>) {
1131+ if !session.read(cx).is_blank() || session.read(cx).is_remote() {
1132+ return;
1133+ }
1134+ let empty = self
1135+ .chats
1136+ .get(&session.entity_id())
1137+ .is_none_or(|chat| {
1138+ let (text, attachments) = chat.read(cx).draft(cx);
1139+ text.trim().is_empty() && attachments == 0
1140+ });
1141+ if empty && let Some(ix) = self.project_of(session) {
1142+ self.remove_thread(ix, session, cx);
1143+ }
9831144 }
9841145
985- /// The settle button on a thread card: settles at once, or arms itself
986- /// for a confirming second click.
987- fn press_settle(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
1146+ /// The key naming a project.
1147+ fn project_key(&self, ix: usize) -> ProjectKey {
1148+ let project = &self.projects[ix];
1149+ (project.path.clone(), project.environment.clone())
1150+ }
1151+
1152+ fn project_by_key(&self, key: &ProjectKey) -> Option<usize> {
1153+ self.projects.iter().position(|project| project.path == key.0 && project.environment == key.1)
1154+ }
1155+
1156+ /// Takes a thread out of its project without stopping it, so the undo
1157+ /// notice can put it back.
1158+ fn detach_thread(&mut self, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) {
9881159 let id = session.entity_id();
989- let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
990- if settings::get(|settings| settings.confirm_settle) && !armed {
991- self.settle_armed = Some((id, Instant::now()));
992- cx.notify();
993- } else {
994- self.settle_thread(session, cx);
1160+ self.chats.remove(&id);
1161+ self.observers.remove(&id);
1162+ self.activity.remove(&id);
1163+ if let Some(project) = self.projects.get_mut(project_ix) {
1164+ project.sessions.retain(|candidate| candidate.entity_id() != id);
9951165 }
1166+ cx.notify();
9961167 }
9971168
998- /// Deletes a thread, asking first when Delete confirmation is on.
999- fn request_delete(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1000- if !settings::get(|settings| settings.confirm_delete) {
1001- self.forget_thread(session, window, cx);
1169+ /// Puts a detached thread back where it was.
1170+ fn reattach_thread(
1171+ &mut self,
1172+ key: &ProjectKey,
1173+ session: Entity<Session>,
1174+ chat: Entity<ChatView>,
1175+ window: &mut Window,
1176+ cx: &mut Context<Self>,
1177+ ) {
1178+ let Some(project_ix) = self.project_by_key(key) else {
1179+ session.update(cx, |session, _| session.shutdown());
10021180 return;
1003- }
1004- let title = session.read(cx).title.clone();
1181+ };
1182+ self.observers.insert(
1183+ session.entity_id(),
1184+ vec![cx.observe_in(&session, window, Self::on_session_changed), cx.subscribe_in(&chat, window, Self::on_chat_event)],
1185+ );
1186+ self.chats.insert(session.entity_id(), chat);
1187+ self.projects[project_ix].sessions.push(session);
1188+ cx.notify();
1189+ }
1190+
1191+ /// Opens a menu at a point in the window, as a right click would.
1192+ fn open_menu(
1193+ &mut self,
1194+ position: Point<Pixels>,
1195+ window: &mut Window,
1196+ cx: &mut Context<Self>,
1197+ build: impl FnOnce(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
1198+ ) {
1199+ // The menus read the workspace as they build, so build once this
1200+ // update is over.
10051201 let workspace = cx.weak_entity();
1006- window.open_alert_dialog(cx, move |alert, _, _| {
1007- let workspace = workspace.clone();
1008- let session = session.clone();
1009- alert
1010- .title("Delete thread?")
1011- .description(format!("\u{201c}{title}\u{201d} and its chat history will be deleted."))
1012- .ok_text("Delete")
1013- .ok_variant(ButtonVariant::Danger)
1014- .show_cancel(true)
1015- .on_ok(move |_, window, cx| {
1016- let _ = workspace.update(cx, |this, cx| this.forget_thread(session.clone(), window, cx));
1017- true
1018- })
1202+ window.defer(cx, move |window, cx| {
1203+ let menu = PopupMenu::build(window, cx, build);
1204+ menu.focus_handle(cx).focus(window, cx);
1205+ let _ = workspace.update(cx, |this, cx| {
1206+ let dismiss = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, window, cx| {
1207+ this.overlay_menu = None;
1208+ // Hand focus back, so the shortcuts keep working.
1209+ match this.active.as_ref().and_then(|session| this.chats.get(&session.entity_id())) {
1210+ Some(chat) if this.renaming.is_none() => chat.update(cx, |chat, cx| chat.focus_composer(window, cx)),
1211+ _ if this.renaming.is_none() => this.focus_handle.focus(window, cx),
1212+ _ => {}
1213+ }
1214+ cx.notify();
1215+ });
1216+ this.overlay_menu = Some(OverlayMenu { menu, position, _dismiss: dismiss });
1217+ cx.notify();
1218+ });
10191219 });
10201220 }
10211221
1022- /// Pins or unpins a thread, asking before unpinning when set to.
1023- fn toggle_pin(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1024- let pinned = session.read(cx).pinned;
1025- if !pinned || !settings::get(|settings| settings.confirm_unpin) {
1026- session.update(cx, |session, cx| session.set_pinned(!pinned, cx));
1027- return;
1222+ /// Edits a thread's title in place: in its sidebar row, or in the chat
1223+ /// header. Enter or leaving the field saves, Esc cancels.
1224+ fn start_rename(&mut self, session: Entity<Session>, sidebar: bool, window: &mut Window, cx: &mut Context<Self>) {
1225+ // A row that isn't showing can't host the field; the header can.
1226+ let sidebar = sidebar && self.sidebar_open && !self.settings_open;
1227+ if !sidebar && self.active.as_ref() != Some(&session) {
1228+ if let Some(ix) = self.project_of(&session) {
1229+ self.select(ix, session.clone(), window, cx);
1230+ }
10281231 }
1029- let title = session.read(cx).title.clone();
1030- window.open_alert_dialog(cx, move |alert, _, _| {
1031- let session = session.clone();
1032- alert
1033- .title("Unpin thread?")
1034- .description(format!("\u{201c}{title}\u{201d} moves back among the other threads."))
1035- .ok_text("Unpin")
1036- .show_cancel(true)
1037- .on_ok(move |_, _, cx| {
1038- session.update(cx, |session, cx| session.set_pinned(false, cx));
1039- true
1040- })
1232+ let title = session.read(cx).title.to_string();
1233+ let input = cx.new(|cx| InputState::new(window, cx).default_value(title));
1234+ input.update(cx, |input, cx| {
1235+ input.focus(window, cx);
1236+ input.select_all(window, cx);
10411237 });
1238+ let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event {
1239+ InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx),
1240+ _ => {}
1241+ });
1242+ self.renaming = Some(InlineRename { session, input, sidebar, _subscription: subscription });
1243+ cx.notify();
10421244 }
10431245
1044- fn rename_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1045- let title = session.read(cx).title.to_string();
1046- let input = cx.new(|cx| InputState::new(window, cx).default_value(title));
1047- input.update(cx, |input, cx| input.focus(window, cx));
1048- let rename = {
1049- let input = input.clone();
1050- let session = session.clone();
1051- move |window: &mut Window, cx: &mut App| {
1052- let title = input.read(cx).value().to_string();
1053- session.update(cx, |session, cx| session.rename(title, cx));
1054- window.close_dialog(cx);
1055- }
1246+ fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1247+ let Some(rename) = self.renaming.take() else { return };
1248+ let title = rename.input.read(cx).value().trim().to_string();
1249+ if title.is_empty() {
1250+ window.push_notification(Notification::warning("Thread title cannot be empty"), cx);
1251+ } else if title != rename.session.read(cx).title.as_ref() {
1252+ rename.session.update(cx, |session, cx| session.rename(title, cx));
1253+ }
1254+ cx.notify();
1255+ }
1256+
1257+ fn cancel_rename(&mut self, cx: &mut Context<Self>) {
1258+ if self.renaming.take().is_some() {
1259+ cx.notify();
1260+ }
1261+ }
1262+
1263+ /// Opens the previous or next thread in the sidebar (Ctrl+Shift+[ and ]).
1264+ fn step_thread(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
1265+ let rows = self.visible_rows(cx);
1266+ if rows.is_empty() {
1267+ return;
1268+ }
1269+ let current = self.active.as_ref().and_then(|active| rows.iter().position(|(_, row)| row == active));
1270+ let next = match current {
1271+ Some(ix) => (ix as isize + delta).rem_euclid(rows.len() as isize) as usize,
1272+ None if delta < 0 => rows.len() - 1,
1273+ None => 0,
10561274 };
1057- let on_enter = rename.clone();
1058- let subscription = cx.subscribe_in(&input, window, move |_, _, event: &InputEvent, window, cx| {
1059- if matches!(event, InputEvent::PressEnter { .. }) {
1060- on_enter(window, cx);
1275+ let (project, session) = rows[next].clone();
1276+ self.select(project, session, window, cx);
1277+ }
1278+
1279+ /// Ctrl+1–9 open the sidebar's first nine threads.
1280+ fn jump_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1281+ let keystroke = &event.keystroke;
1282+ let modifiers = &keystroke.modifiers;
1283+ if !modifiers.control || modifiers.shift || modifiers.alt || modifiers.platform || self.palette.is_some() {
1284+ return;
1285+ }
1286+ let Some(digit) = keystroke.key.parse::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else { return };
1287+ if let Some((project, session)) = self.visible_rows(cx).get(digit - 1).cloned() {
1288+ cx.stop_propagation();
1289+ self.jump_hints = false;
1290+ self.select(project, session, window, cx);
1291+ }
1292+ }
1293+
1294+ /// Shows the jump hints once Ctrl has been held for a beat.
1295+ fn modifiers_changed(&mut self, event: &ModifiersChangedEvent, window: &mut Window, cx: &mut Context<Self>) {
1296+ let held = event.modifiers.control && !event.modifiers.shift && !event.modifiers.alt;
1297+ if !held {
1298+ self.ctrl_held = None;
1299+ if self.jump_hints {
1300+ self.jump_hints = false;
1301+ cx.notify();
10611302 }
1062- });
1063- let rename = Rc::new(rename);
1064- window.open_dialog(cx, move |dialog, _, cx| {
1065- let p = metal::palette(cx);
1066- let rename = rename.clone();
1067- dialog
1068- .title("Rename thread")
1069- .w(px(420.))
1070- .child(
1071- h_flex()
1072- .h(px(36.))
1073- .px(px(11.))
1074- .rounded(px(12.))
1075- .well(p)
1076- .text_size(px(13.5))
1077- .child(div().flex_1().child(Input::new(&input).appearance(false))),
1078- )
1079- .footer(
1080- h_flex()
1081- .w_full()
1082- .justify_end()
1083- .gap(px(8.))
1084- .child(
1085- MetalButton::new("rename-cancel")
1086- .ghost()
1087- .compact()
1088- .label("Cancel")
1089- .on_click(|_, window, cx| window.close_dialog(cx)),
1090- )
1091- .child(
1092- MetalButton::new("rename-save")
1093- .primary()
1094- .compact()
1095- .label("Rename")
1096- .on_click(move |_, window, cx| rename(window, cx)),
1097- ),
1098- )
1099- });
1100- // The subscription lives as long as the dialog might.
1101- self._subscriptions.push(subscription);
1303+ return;
1304+ }
1305+ if self.ctrl_held.is_some() {
1306+ return;
1307+ }
1308+ let since = Instant::now();
1309+ self.ctrl_held = Some(since);
1310+ cx.spawn_in(window, async move |this, cx| {
1311+ cx.background_executor().timer(JUMP_HINT_DELAY).await;
1312+ let _ = this.update(cx, |this, cx| {
1313+ if this.ctrl_held == Some(since) {
1314+ this.jump_hints = true;
1315+ cx.notify();
1316+ }
1317+ });
1318+ })
1319+ .detach();
1320+ }
1321+
1322+ /// Opens a settings page by its name in the settings view (`Usage`).
1323+ fn open_settings_page(&mut self, page: &str, window: &mut Window, cx: &mut Context<Self>) {
1324+ self.open_settings(window, cx);
1325+ self.settings_view.update(cx, |view, cx| view.open_page(page, cx));
1326+ }
1327+
1328+ /// The settle button on a thread card: settles at once, or arms itself
1329+ /// for a confirming second click when Settle confirmation is on.
1330+ fn press_settle(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
1331+ let id = session.entity_id();
1332+ let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
1333+ if settings::get(|settings| settings.confirm_settle) && !armed {
1334+ self.settle_armed = Some((id, Instant::now()));
1335+ cx.notify();
1336+ } else {
1337+ self.settle_threads(vec![session], window, cx);
1338+ }
11021339 }
11031340
11041341 fn delete_thread(
@@ -1112,21 +1349,51 @@ impl Workspace {
11121349 let state = session.read(cx);
11131350 (state.id.clone(), state.worktree.clone(), state.is_remote())
11141351 };
1352+ // Like T3 Code, a worktree goes too only when no other thread uses it,
1353+ // and only once the user says so.
1354+ let entity_id = session.entity_id();
1355+ let worktree = worktree.filter(|worktree| {
1356+ worktree.root.exists()
1357+ && !self.projects.iter().flat_map(|project| &project.sessions).any(|other| {
1358+ other.entity_id() != entity_id
1359+ && other.read(cx).worktree.as_ref().is_some_and(|other| other.root == worktree.root)
1360+ })
1361+ });
11151362 self.remove_thread(project_ix, &session, cx);
11161363 if remote {
11171364 session.read(cx).forget_remote();
11181365 } else {
11191366 cx.background_executor()
1120- .spawn(async move {
1121- store::delete_thread(&id);
1122- browser::stop(&browser::session_name(&id));
1123- if let Some(worktree) = worktree {
1124- let _ = worktree::remove(&worktree);
1125- }
1126- })
1127- .detach();
1367+ .spawn(async move {
1368+ store::delete_thread(&id);
1369+ browser::stop(&browser::session_name(&id));
1370+ })
1371+ .detach();
1372+ if let Some(worktree) = worktree {
1373+ let name = worktree.root.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default();
1374+ let message =
1375+ format!("This thread is the only one linked to this worktree:\n{name}\n\nDelete the worktree too?");
1376+ // After the dialog that asked to delete the thread has closed.
1377+ window.defer(cx, move |window, cx| actions::confirm(&message, true, window, cx, move |window, cx| {
1378+ let worktree = worktree.clone();
1379+ let name = name.clone();
1380+ let task = cx.background_executor().spawn(async move { worktree::remove_force(&worktree) });
1381+ window
1382+ .spawn(cx, async move |cx| {
1383+ if let Err(err) = task.await {
1384+ let _ = cx.update(|window, cx| {
1385+ window.push_notification(
1386+ Notification::error(format!("Could not remove {name}. {err}"))
1387+ .title("Failed to delete worktree"),
1388+ cx,
1389+ );
1390+ });
1391+ }
1392+ })
1393+ .detach();
1394+ }));
1395+ }
11281396 }
1129- let entity_id = session.entity_id();
11301397 if self.active.as_ref().is_some_and(|active| active.entity_id() == entity_id) {
11311398 self.active = None;
11321399 if let Some((ix, next)) = self.threads(cx).0.into_iter().next() {
@@ -1327,13 +1594,14 @@ impl Workspace {
13271594 cx.notify();
13281595 }
13291596
1330- /// Every settled thread, most recently active first.
1597+ /// Every archived thread, most recently active first, for Settings →
1598+ /// Archive. (Settled threads stay in the sidebar's Settled shelf.)
13311599 pub fn settled_threads(&self, cx: &App) -> Vec<SettledThread> {
13321600 let mut threads: Vec<_> = self
13331601 .projects
13341602 .iter()
13351603 .flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
1336- .filter(|(_, session)| session.read(cx).settled)
1604+ .filter(|(_, session)| session.read(cx).archived)
13371605 .collect();
13381606 threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
13391607 threads
@@ -1356,7 +1624,10 @@ impl Workspace {
13561624 /// Brings a settled thread back and opens it.
13571625 pub fn restore_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
13581626 let Some(ix) = self.project_of(&session) else { return };
1359- session.update(cx, |session, cx| session.unsettle(cx));
1627+ session.update(cx, |session, cx| {
1628+ session.set_archived(false, cx);
1629+ session.unsettle(cx);
1630+ });
13601631 self.select(ix, session, window, cx);
13611632 self.close_settings(window, cx);
13621633 }
@@ -1367,619 +1638,6 @@ impl Workspace {
13671638 }
13681639 }
13691640
1370- // ---- sidebar --------------------------------------------------------
1371-
1372- fn render_sidebar_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
1373- let p = metal::palette(cx);
1374- h_flex()
1375- .flex_none()
1376- .h(px(52.))
1377- .pl(px(10.))
1378- .pr(px(12.))
1379- .gap(px(8.))
1380- .child(
1381- MetalButton::new("hide-sidebar")
1382- .ghost()
1383- .mini()
1384- .icon(Lucide::PanelLeftClose)
1385- .tooltip("Hide sidebar (Ctrl+B)")
1386- .on_click(cx.listener(|this, _, _, cx| {
1387- this.sidebar_open = false;
1388- cx.notify();
1389- })),
1390- )
1391- .child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
1392- .child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
1393- .child(metal::tag("Code", p))
1394- }
1395-
1396- fn render_sidebar_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1397- let p = metal::palette(cx);
1398- let projects: Vec<(usize, SharedString)> = self
1399- .ordered_projects(cx)
1400- .into_iter()
1401- .map(|ix| {
1402- let project = &self.projects[ix];
1403- let name = match &project.environment {
1404- Some(env) => format!("{} · {}", project.name, self.environment_name(env)).into(),
1405- None => project.name.clone(),
1406- };
1407- (ix, name)
1408- })
1409- .collect();
1410- let workspace = cx.entity().downgrade();
1411-
1412- h_flex()
1413- .flex_none()
1414- .px(px(12.))
1415- .gap(px(4.))
1416- .child(
1417- h_flex()
1418- .flex_1()
1419- .min_w_0()
1420- .h(px(36.))
1421- .pl(px(11.))
1422- .pr(px(8.))
1423- .gap(px(8.))
1424- .rounded(px(14.))
1425- .well(p)
1426- .text_size(px(13.))
1427- .text_color(p.ink)
1428- .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink))
1429- .child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))),
1430- )
1431- .child(
1432- MetalButton::new("project-menu")
1433- .ghost()
1434- .mini()
1435- .icon(Lucide::FolderPlus)
1436- .dropdown_menu(move |mut menu, _, _| {
1437- for (ix, name) in projects.clone() {
1438- let workspace = workspace.clone();
1439- menu = menu.item(PopupMenuItem::new(format!("New thread in {name}")).on_click(
1440- move |_, window, cx| {
1441- let _ = workspace.update(cx, |this, cx| this.new_thread(ix, window, cx));
1442- },
1443- ));
1444- }
1445- let workspace = workspace.clone();
1446- menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(
1447- move |_, window, cx| {
1448- let _ = workspace.update(cx, |this, cx| this.prompt_for_project(window, cx));
1449- },
1450- ))
1451- }),
1452- )
1453- .child(
1454- MetalButton::new("new-thread")
1455- .ghost()
1456- .mini()
1457- .icon(Lucide::SquarePen)
1458- .tooltip("New thread (Ctrl+N)")
1459- .on_click(cx.listener(|this, _, window, cx| {
1460- if this.projects.is_empty() {
1461- this.prompt_for_project(window, cx);
1462- } else {
1463- this.new_thread(this.active_project, window, cx);
1464- }
1465- })),
1466- )
1467- }
1468-
1469- /// The right-click menu of a thread card.
1470- fn thread_menu(
1471- workspace: WeakEntity<Self>,
1472- session: Entity<Session>,
1473- menu: PopupMenu,
1474- window: &mut Window,
1475- cx: &mut Context<PopupMenu>,
1476- ) -> PopupMenu {
1477- let state = session.read(cx);
1478- let (pinned, settled, snoozed) = (state.pinned, state.settled, state.is_snoozed());
1479- let action = |run: fn(&mut Workspace, Entity<Session>, &mut Window, &mut Context<Workspace>)| {
1480- let workspace = workspace.clone();
1481- let session = session.clone();
1482- move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
1483- let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx));
1484- }
1485- };
1486- let snooze_session = session.clone();
1487- let mut menu = menu
1488- .min_w(px(200.))
1489- .item(
1490- PopupMenuItem::new(if pinned { "Unpin" } else { "Pin to top" })
1491- .icon(Icon::new(if pinned { Lucide::PinOff } else { Lucide::Pin }))
1492- .on_click(action(|this, session, window, cx| this.toggle_pin(session, window, cx))),
1493- )
1494- .item(
1495- PopupMenuItem::new("Rename…")
1496- .icon(Icon::new(Lucide::Pencil))
1497- .on_click(action(|this, session, window, cx| this.rename_thread(session, window, cx))),
1498- );
1499- if !settled {
1500- menu = menu.submenu_with_icon(Some(Icon::new(Lucide::AlarmClock)), "Snooze", window, cx, move |mut menu, _, _| {
1501- let snooze = |until: fn() -> SystemTime| {
1502- let session = snooze_session.clone();
1503- move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
1504- session.update(cx, |session, cx| session.snooze(Some(until()), cx));
1505- }
1506- };
1507- menu = menu
1508- .min_w(px(220.))
1509- .item(PopupMenuItem::new("For an hour").on_click(snooze(|| SystemTime::now() + Duration::from_secs(3_600))))
1510- .item(PopupMenuItem::new("For three hours").on_click(snooze(|| SystemTime::now() + Duration::from_secs(10_800))))
1511- .item(PopupMenuItem::new(format!("Until tomorrow, {}", clock::clock(clock::at_hour(1, 9)))).on_click(snooze(|| clock::at_hour(1, 9))))
1512- .item(PopupMenuItem::new(format!("Until next week, {}", clock::clock(clock::at_hour(7, 9)))).on_click(snooze(|| clock::at_hour(7, 9))));
1513- menu
1514- });
1515- if snoozed {
1516- let session = session.clone();
1517- menu = menu.item(PopupMenuItem::new("Wake up now").icon(Icon::new(Lucide::AlarmClockOff)).on_click(
1518- move |_, _, cx| session.update(cx, |session, cx| session.snooze(None, cx)),
1519- ));
1520- }
1521- }
1522- menu = menu.separator();
1523- menu = if settled {
1524- menu.item(
1525- PopupMenuItem::new("Restore")
1526- .icon(Icon::new(Lucide::ArchiveRestore))
1527- .on_click(action(|this, session, window, cx| this.restore_thread(session, window, cx))),
1528- )
1529- } else {
1530- menu.item(
1531- PopupMenuItem::new("Settle")
1532- .icon(Icon::new(Lucide::Archive))
1533- .on_click(action(|this, session, _, cx| this.settle_thread(session, cx))),
1534- )
1535- };
1536- menu.item(
1537- PopupMenuItem::new("Delete…")
1538- .icon(Icon::new(Lucide::Trash))
1539- .on_click(action(|this, session, window, cx| this.request_delete(session, window, cx))),
1540- )
1541- }
1542-
1543- fn render_thread_card(
1544- &self,
1545- project_ix: usize,
1546- session: &Entity<Session>,
1547- cx: &mut Context<Self>,
1548- ) -> AnyElement {
1549- let p = metal::palette(cx);
1550- let project = &self.projects[project_ix];
1551- let state = session.read(cx);
1552- let id = session.entity_id();
1553- let selected = self.active.as_ref().is_some_and(|active| active.entity_id() == id);
1554- let (led, pulse) = if state.settled {
1555- (Led::Off, false)
1556- } else if state.needs_attention() {
1557- (Led::Waiting, true)
1558- } else if state.limited_until().is_some() {
1559- (Led::Waiting, false)
1560- } else {
1561- match state.status {
1562- SessionStatus::Working => (Led::Live, true),
1563- // Idle, but a background task will wake Claude.
1564- SessionStatus::Idle if !state.background_tasks.is_empty() => (Led::Live, true),
1565- SessionStatus::Idle => (Led::Live, false),
1566- SessionStatus::Starting => (Led::Off, false),
1567- SessionStatus::Exited => (Led::Failed, false),
1568- }
1569- };
1570- let branch = match &state.worktree {
1571- Some(worktree) => Some(worktree.branch.clone()),
1572- None => project.git.as_ref().and_then(|git| git.branch.clone()),
1573- };
1574- let in_worktree = state.worktree.is_some() || state.pending_worktree.is_some();
1575- let host = project.environment.as_deref().map(|env| self.environment_name(env));
1576- let settled = state.settled;
1577- let pinned = state.pinned;
1578- let snoozed_until = state.snoozed_until.filter(|_| state.is_snoozed());
1579- let group = SharedString::from(format!("thread-{id}"));
1580- let select_session = session.clone();
1581- let action_session = session.clone();
1582- let pin_session = session.clone();
1583- let compact = settings::get(|settings| settings.compact_threads);
1584- let armed = self.settle_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < SETTLE_ARMED);
1585-
1586- let settle_or_delete = if settled {
1587- MetalButton::new(("delete-thread", id.as_u64() as usize))
1588- .ghost()
1589- .mini()
1590- .icon(IconName::Close)
1591- .tooltip("Delete thread")
1592- .on_click(cx.listener(move |this, _, window, cx| {
1593- cx.stop_propagation();
1594- this.request_delete(action_session.clone(), window, cx)
1595- }))
1596- } else if armed {
1597- MetalButton::new(("settle-thread", id.as_u64() as usize))
1598- .danger()
1599- .mini()
1600- .icon(IconName::Check)
1601- .tooltip("Click again to settle")
1602- .on_click(cx.listener(move |this, _, _, cx| {
1603- cx.stop_propagation();
1604- this.press_settle(action_session.clone(), cx)
1605- }))
1606- } else {
1607- MetalButton::new(("settle-thread", id.as_u64() as usize))
1608- .ghost()
1609- .mini()
1610- .icon(Lucide::Archive)
1611- .tooltip("Settle: stop the agent and file the thread away")
1612- .on_click(cx.listener(move |this, _, _, cx| {
1613- cx.stop_propagation();
1614- this.press_settle(action_session.clone(), cx)
1615- }))
1616- };
1617- let pin_button = MetalButton::new(("pin-thread", id.as_u64() as usize))
1618- .ghost()
1619- .mini()
1620- .icon(if pinned { Lucide::PinOff } else { Lucide::Pin })
1621- .tooltip(if pinned { "Unpin" } else { "Pin to top" })
1622- .on_click(cx.listener(move |this, _, window, cx| {
1623- cx.stop_propagation();
1624- this.toggle_pin(pin_session.clone(), window, cx)
1625- }));
1626- let actions = h_flex()
1627- .gap(px(1.))
1628- .when(!armed, |this| this.invisible().group_hover(group.clone(), |this| this.visible()))
1629- .when(!settled, |this| this.child(pin_button))
1630- .child(settle_or_delete);
1631- let led = metal::led(("thread-led", id.as_u64() as usize), led, pulse, p);
1632- let title = h_flex()
1633- .min_w_0()
1634- .gap(px(5.))
1635- .when(pinned, |this| this.child(Icon::new(Lucide::Pin).size(px(11.)).text_color(p.ink3).flex_none()))
1636- .child(
1637- div()
1638- .min_w_0()
1639- .truncate()
1640- .text_size(px(13.5))
1641- .font_weight(FontWeight::SEMIBOLD)
1642- .text_color(if selected || !settled { p.ink } else { p.ink2 })
1643- .child(state.title.clone()),
1644- );
1645- let age = div()
1646- .flex_none()
1647- .font_family(metal::MONO)
1648- .text_size(px(10.5))
1649- .text_color(p.ink3)
1650- .child(match snoozed_until {
1651- Some(until) => clock::when(until),
1652- None => relative_time(state.updated),
1653- });
1654-
1655- let workspace = cx.weak_entity();
1656- let menu_session = session.clone();
1657- let card = v_flex()
1658- .id(("thread", id.as_u64() as usize))
1659- .group(group)
1660- .px(px(12.))
1661- .py(px(if compact { 5. } else { 9. }))
1662- .gap(px(4.))
1663- .rounded(px(if compact { 12. } else { 14. }))
1664- .cursor_pointer()
1665- .map(|this| {
1666- if selected {
1667- this.raised(p)
1668- } else {
1669- this.hover(|style| style.bg(p.row_hover))
1670- }
1671- })
1672- .on_click(cx.listener(move |this, _, window, cx| {
1673- this.select(project_ix, select_session.clone(), window, cx)
1674- }));
1675-
1676- let card = if compact {
1677- card.child(
1678- h_flex()
1679- .h(px(22.))
1680- .gap(px(7.))
1681- .child(metal::badge(project.initials.clone(), project.tint, p))
1682- .child(title.flex_1().text_size(px(13.)).font_weight(FontWeight::MEDIUM))
1683- .child(actions)
1684- .child(age)
1685- .child(led),
1686- )
1687- } else {
1688- card.child(
1689- h_flex()
1690- .gap(px(7.))
1691- .child(metal::badge(project.initials.clone(), project.tint, p))
1692- .child(
1693- div()
1694- .flex_1()
1695- .min_w_0()
1696- .truncate()
1697- .text_size(px(12.))
1698- .font_weight(FontWeight::MEDIUM)
1699- .text_color(p.ink3)
1700- .child(project.name.clone()),
1701- )
1702- .when_some(host, |this, host| this.child(metal::tag(host, p)))
1703- .when(snoozed_until.is_some(), |this| {
1704- this.child(Icon::new(Lucide::AlarmClock).size(px(11.)).text_color(p.ink3))
1705- })
1706- .child(age),
1707- )
1708- .child(title)
1709- .child(
1710- h_flex()
1711- .h(px(18.))
1712- .gap(px(6.))
1713- .text_color(p.ink3)
1714- .when_some(branch, |this, branch| {
1715- this.child(Icon::new(if in_worktree { Lucide::FolderGit2 } else { Lucide::GitBranch }).size(px(11.)))
1716- .child(
1717- div()
1718- .min_w_0()
1719- .truncate()
1720- .font_family(metal::MONO)
1721- .text_size(px(10.5))
1722- .child(branch),
1723- )
1724- })
1725- .child(div().flex_1())
1726- .child(pickers::provider_icon(state.provider, px(11.), p))
1727- .child(actions)
1728- .child(led),
1729- )
1730- };
1731- card.context_menu(move |menu, window, cx| {
1732- Self::thread_menu(workspace.clone(), menu_session.clone(), menu, window, cx)
1733- })
1734- .into_any_element()
1735- }
1736-
1737- /// A foldable section header in the thread list: `Settled (3) ——— ›`.
1738- fn render_fold(
1739- &self,
1740- id: &'static str,
1741- label: String,
1742- open: bool,
1743- toggle: fn(&mut Self),
1744- cx: &mut Context<Self>,
1745- ) -> AnyElement {
1746- let p = metal::palette(cx);
1747- h_flex()
1748- .id(id)
1749- .mt(px(10.))
1750- .h(px(30.))
1751- .px(px(12.))
1752- .gap(px(8.))
1753- .rounded(px(10.))
1754- .cursor_pointer()
1755- .text_size(px(12.5))
1756- .font_weight(FontWeight::MEDIUM)
1757- .text_color(p.ink3)
1758- .hover(|style| style.bg(p.row_hover).text_color(p.ink))
1759- .on_click(cx.listener(move |this, _, _, cx| {
1760- toggle(this);
1761- cx.notify();
1762- }))
1763- .child(label)
1764- .child(div().flex_1().h(px(1.)).bg(p.rule))
1765- .child(Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(12.)))
1766- .into_any_element()
1767- }
1768-
1769- /// A project's header in the grouped thread list.
1770- fn render_project_header(&self, ix: usize, count: usize, cx: &mut Context<Self>) -> AnyElement {
1771- let p = metal::palette(cx);
1772- let project = &self.projects[ix];
1773- let collapsed = self.collapsed_projects.contains(&project.path);
1774- let path = project.path.clone();
1775- h_flex()
1776- .id(("project-group", ix))
1777- .group(SharedString::from(format!("project-group-{ix}")))
1778- .mt(px(8.))
1779- .h(px(30.))
1780- .pl(px(10.))
1781- .pr(px(4.))
1782- .gap(px(7.))
1783- .rounded(px(10.))
1784- .cursor_pointer()
1785- .hover(|style| style.bg(p.row_hover))
1786- .on_click(cx.listener(move |this, _, _, cx| {
1787- if !this.collapsed_projects.remove(&path) {
1788- this.collapsed_projects.insert(path.clone());
1789- }
1790- cx.notify();
1791- }))
1792- .child(Icon::new(if collapsed { IconName::ChevronRight } else { IconName::ChevronDown }).size(px(12.)).text_color(p.ink3))
1793- .child(metal::badge(project.initials.clone(), project.tint, p))
1794- .child(
1795- div()
1796- .flex_1()
1797- .min_w_0()
1798- .truncate()
1799- .text_size(px(12.5))
1800- .font_weight(FontWeight::SEMIBOLD)
1801- .text_color(p.ink2)
1802- .child(project.name.clone()),
1803- )
1804- .child(div().font_family(metal::MONO).text_size(px(10.5)).text_color(p.ink3).child(count.to_string()))
1805- .child(
1806- div().invisible().group_hover(SharedString::from(format!("project-group-{ix}")), |this| this.visible()).child(
1807- MetalButton::new(("project-new-thread", ix))
1808- .ghost()
1809- .mini()
1810- .icon(Lucide::SquarePen)
1811- .tooltip(format!("New thread in {}", project.name))
1812- .on_click(cx.listener(move |this, _, window, cx| {
1813- cx.stop_propagation();
1814- this.new_thread(ix, window, cx);
1815- })),
1816- ),
1817- )
1818- .into_any_element()
1819- }
1820-
1821- fn render_thread_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
1822- let p = metal::palette(cx);
1823- let (open, settled) = self.threads(cx);
1824- let searching = !self.search.read(cx).value().trim().is_empty();
1825- let (group, working_section) = settings::get(|settings| (settings.group_by_project, settings.working_section));
1826-
1827- // Pinned threads stay on top; snoozed ones wait below; working ones
1828- // fold away (when set to) until they need the user.
1829- let mut pinned = Vec::new();
1830- let mut snoozed = Vec::new();
1831- let mut working = Vec::new();
1832- let mut rest = Vec::new();
1833- for entry in open {
1834- let state = entry.1.read(cx);
1835- if state.is_snoozed() {
1836- snoozed.push(entry);
1837- } else if state.pinned {
1838- pinned.push(entry);
1839- } else if working_section && state.is_working() && !state.needs_attention() {
1840- working.push(entry);
1841- } else {
1842- rest.push(entry);
1843- }
1844- }
1845- let is_empty = pinned.is_empty() && snoozed.is_empty() && working.is_empty() && rest.is_empty() && settled.is_empty();
1846- let mut children: Vec<AnyElement> = Vec::new();
1847- if !pinned.is_empty() {
1848- children.push(
1849- div()
1850- .px(px(12.))
1851- .pt(px(2.))
1852- .pb(px(4.))
1853- .child(metal::engraved(format!("Pinned · {}", pinned.len()), p))
1854- .into_any_element(),
1855- );
1856- children.extend(pinned.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1857- if !rest.is_empty() || !working.is_empty() {
1858- children.push(div().px(px(12.)).py(px(6.)).child(metal::rule(p)).into_any_element());
1859- }
1860- }
1861- if !working.is_empty() {
1862- let open = self.working_open || searching;
1863- children.push(self.render_fold(
1864- "working-toggle",
1865- format!("Working ({})", working.len()),
1866- open,
1867- |this| this.working_open = !this.working_open,
1868- cx,
1869- ));
1870- if open {
1871- children.extend(working.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1872- }
1873- }
1874- if group {
1875- for ix in self.ordered_projects(cx) {
1876- let threads: Vec<_> = rest.iter().filter(|(project, _)| *project == ix).collect();
1877- if threads.is_empty() {
1878- continue;
1879- }
1880- children.push(self.render_project_header(ix, threads.len(), cx));
1881- if searching || !self.collapsed_projects.contains(&self.projects[ix].path) {
1882- children.extend(threads.into_iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1883- }
1884- }
1885- } else {
1886- children.extend(rest.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1887- }
1888- for (id, label, threads, open, toggle) in [
1889- (
1890- "snoozed-toggle",
1891- "Snoozed",
1892- snoozed,
1893- self.snoozed_open || searching,
1894- (|this: &mut Self| this.snoozed_open = !this.snoozed_open) as fn(&mut Self),
1895- ),
1896- (
1897- "settled-toggle",
1898- "Settled",
1899- settled,
1900- self.settled_open || searching,
1901- |this: &mut Self| this.settled_open = !this.settled_open,
1902- ),
1903- ] {
1904- if threads.is_empty() {
1905- continue;
1906- }
1907- children.push(self.render_fold(id, format!("{label} ({})", threads.len()), open, toggle, cx));
1908- if open {
1909- children.extend(threads.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)));
1910- }
1911- }
1912-
1913- v_flex()
1914- .id("threads")
1915- .flex_1()
1916- .min_h_0()
1917- .overflow_y_scroll()
1918- .px(px(8.))
1919- .pt(px(12.))
1920- .pb(px(12.))
1921- .gap(px(3.))
1922- .when(is_empty, |this| {
1923- this.child(
1924- div()
1925- .px(px(12.))
1926- .py(px(8.))
1927- .text_size(px(12.5))
1928- .text_color(p.ink3)
1929- .child(if searching { "No threads match." } else { "No threads yet." }),
1930- )
1931- })
1932- .children(children)
1933- }
1934-
1935- fn render_sidebar_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
1936- let p = metal::palette(cx);
1937- let catalog = Catalog::global(cx);
1938- let claude = catalog.first_of(Provider::Claude);
1939- let account = claude.and_then(|state| state.account.clone());
1940- let checking = claude.is_some_and(|state| state.checking || state.checked.is_none());
1941- let workspace = cx.entity().downgrade();
1942-
1943- h_flex()
1944- .flex_none()
1945- .h(px(48.))
1946- .px(px(12.))
1947- .gap(px(8.))
1948- .child(
1949- MetalButton::new("settings")
1950- .ghost()
1951- .compact()
1952- .icon(IconName::Settings2)
1953- .label("Settings")
1954- .tooltip("Settings (Ctrl+,)")
1955- .on_click(move |_, window, cx| {
1956- let _ = workspace.update(cx, |this, cx| this.open_settings(window, cx));
1957- }),
1958- )
1959- .child(div().flex_1())
1960- .child(
1961- div()
1962- .min_w_0()
1963- .truncate()
1964- .font_family(metal::MONO)
1965- .text_size(px(10.5))
1966- .text_color(p.ink3)
1967- .child(account.clone().unwrap_or_else(|| if checking { "Connecting…" } else { "Not signed in" }.into())),
1968- )
1969- .child(metal::led("account-led", if account.is_some() { Led::Live } else { Led::Off }, false, p))
1970- }
1971-
1972- fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
1973- v_flex()
1974- .w(px(SIDEBAR_WIDTH))
1975- .h_full()
1976- .flex_none()
1977- .child(self.render_sidebar_header(cx))
1978- .child(self.render_sidebar_toolbar(cx))
1979- .child(self.render_thread_list(cx))
1980- .child(self.render_sidebar_footer(cx))
1981- }
1982-
19831641 fn toggle_project_panel(&mut self, cx: &mut Context<Self>) {
19841642 // With the browser in its place, the project panel comes back.
19851643 if self.browser_open {
@@ -2023,92 +1681,6 @@ impl Workspace {
20231681 panel
20241682 }
20251683
2026- /// The browser button, lit when the open thread's browser runs while
2027- /// the panel is closed, as when the agent opens a page.
2028- fn render_browser_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
2029- let p = metal::palette(cx);
2030- let live = !self.browser_open
2031- && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx));
2032- div()
2033- .relative()
2034- .child(
2035- MetalButton::new("toggle-browser")
2036- .ghost()
2037- .mini()
2038- .icon(IconName::Globe)
2039- .tooltip("Browser (Ctrl+Shift+E)")
2040- .on_click(cx.listener(|this, _, _, cx| {
2041- this.browser_open = !this.browser_open;
2042- cx.notify();
2043- })),
2044- )
2045- .when(live, |this| {
2046- this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p)))
2047- })
2048- }
2049-
2050- fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
2051- let p = metal::palette(cx);
2052- let project = self.active_project();
2053- let title = self.active.as_ref().map(|session| session.read(cx).title.clone());
2054-
2055- v_flex()
2056- .flex_none()
2057- .child(
2058- h_flex()
2059- .h(px(52.))
2060- .pl(px(if self.sidebar_open { 20. } else { 12. }))
2061- .pr(px(12.))
2062- .gap(px(10.))
2063- .when(!self.sidebar_open, |this| {
2064- this.child(
2065- MetalButton::new("show-sidebar")
2066- .ghost()
2067- .mini()
2068- .icon(Lucide::PanelLeftOpen)
2069- .tooltip("Show sidebar (Ctrl+B)")
2070- .on_click(cx.listener(|this, _, _, cx| {
2071- this.sidebar_open = true;
2072- cx.notify();
2073- })),
2074- )
2075- })
2076- .when_some(project, |this, project| {
2077- this.child(metal::badge(project.initials.clone(), project.tint, p)).child(
2078- div()
2079- .flex_none()
2080- .text_size(px(14.))
2081- .font_weight(FontWeight::MEDIUM)
2082- .text_color(p.ink2)
2083- .child(project.name.clone()),
2084- )
2085- })
2086- .when_some(title, |this, title| {
2087- this.child(div().text_size(px(14.)).text_color(p.ink3).child("/")).child(
2088- div()
2089- .flex_1()
2090- .min_w_0()
2091- .truncate()
2092- .text_size(px(14.))
2093- .font_weight(FontWeight::SEMIBOLD)
2094- .text_color(p.ink)
2095- .child(title),
2096- )
2097- })
2098- .child(div().flex_1())
2099- .child(self.render_browser_toggle(cx))
2100- .child(
2101- MetalButton::new("toggle-panel")
2102- .ghost()
2103- .mini()
2104- .icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen })
2105- .tooltip("Project panel (Ctrl+Shift+B)")
2106- .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))),
2107- ),
2108- )
2109- .child(metal::rule(p))
2110- }
2111-
21121684 /// One row of the project panel: an icon, a label and an optional trailer.
21131685 fn panel_row(icon: impl Into<Icon>, label: impl Into<SharedString>, p: &Palette) -> Div {
21141686 h_flex()
@@ -2450,12 +2022,69 @@ impl Render for Workspace {
24502022 .text_color(p.ink)
24512023 .font_family(cx.theme().font_family.clone())
24522024 .on_action(cx.listener(|this, _: &NewThread, window, cx| {
2453- if this.projects.is_empty() {
2454- this.prompt_for_project(window, cx);
2025+ // T3's chat.new: the project picker once there's a choice.
2026+ if this.legacy_sidebar() {
2027+ this.new_thread_button(true, window, cx);
24552028 } else {
2456- this.new_thread(this.active_project, window, cx);
2029+ this.new_thread_button(false, window, cx);
2030+ }
2031+ }))
2032+ .on_action(cx.listener(|this, _: &NewThreadLocal, window, cx| this.new_thread_button(true, window, cx)))
2033+ .on_action(cx.listener(|this, _: &NewThreadWithoutProject, window, cx| {
2034+ this.new_thread_without_project(window, cx)
2035+ }))
2036+ .on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| this.toggle_palette(window, cx)))
2037+ .on_action(cx.listener(|this, _: &PreviousThread, window, cx| this.step_thread(-1, window, cx)))
2038+ .on_action(cx.listener(|this, _: &NextThread, window, cx| this.step_thread(1, window, cx)))
2039+ .on_action(cx.listener(|this, _: &SettleThread, window, cx| {
2040+ if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
2041+ this.toggle_settled(session, window, cx);
2042+ }
2043+ }))
2044+ .on_action(cx.listener(|this, _: &PinThread, window, cx| {
2045+ if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
2046+ this.toggle_pin(session, window, cx);
24572047 }
24582048 }))
2049+ .on_action(cx.listener(|this, _: &UndoThreadAction, window, cx| {
2050+ if !this.undo_latest(window, cx) {
2051+ cx.propagate();
2052+ }
2053+ }))
2054+ .on_action(cx.listener(|this, _: &CopyThreadReference, window, cx| {
2055+ if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) {
2056+ Self::copy_thread_reference(&session, window, cx);
2057+ }
2058+ }))
2059+ .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx)))
2060+ .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| {
2061+ this.browser_open = !this.browser_open;
2062+ cx.notify();
2063+ }))
2064+ .on_action(cx.listener(|this, _: &SelectTheme, window, cx| {
2065+ this.open_palette(palette::PaletteView::Themes, window, cx)
2066+ }))
2067+ .on_action(cx.listener(|this, _: &CycleAppearance, window, cx| this.cycle_appearance(window, cx)))
2068+ .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.jump_with_key(event, window, cx)))
2069+ // Dragging the sidebar's edge.
2070+ .when(self.resizing_sidebar, |this| {
2071+ this.cursor_col_resize()
2072+ .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
2073+ let max = (window.viewport_size().width.as_f32() - MAIN_MIN_WIDTH).max(SIDEBAR_MIN_WIDTH);
2074+ this.sidebar_width = event.position.x.as_f32().clamp(SIDEBAR_MIN_WIDTH, max);
2075+ cx.notify();
2076+ }))
2077+ .on_mouse_up(
2078+ MouseButton::Left,
2079+ cx.listener(|this, _, _, cx| {
2080+ this.resizing_sidebar = false;
2081+ cx.notify();
2082+ }),
2083+ )
2084+ })
2085+ .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| {
2086+ this.modifiers_changed(event, window, cx)
2087+ }))
24592088 .on_action(cx.listener(|this, _: &OpenProject, window, cx| {
24602089 this.prompt_for_project(window, cx)
24612090 }))
@@ -2482,7 +2111,7 @@ impl Render for Workspace {
24822111 }
24832112 }))
24842113 .when(self.settings_open, |this| this.child(self.settings_view.clone()))
2485- .when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(cx)))
2114+ .when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(window, cx)))
24862115 .when(!self.settings_open, |this| this.child(
24872116 div()
24882117 .flex_1()
@@ -2511,6 +2140,37 @@ impl Render for Workspace {
25112140 ),
25122141 ),
25132142 ))
2143+ // The command palette floats over everything; a click beside it closes it.
2144+ .when_some(self.palette.as_ref().map(|(palette, _)| palette.clone()), |this, palette| {
2145+ this.child(
2146+ div()
2147+ .absolute()
2148+ .inset_0()
2149+ .flex()
2150+ .flex_col()
2151+ .items_center()
2152+ .pt(window.viewport_size().height * 0.12)
2153+ .px(px(16.))
2154+ .bg(black().opacity(if p.dark { 0.4 } else { 0.16 }))
2155+ .occlude()
2156+ .on_mouse_down(
2157+ MouseButton::Left,
2158+ cx.listener(|this, _, window, cx| this.close_palette(window, cx)),
2159+ )
2160+ .child(div().on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()).child(palette)),
2161+ )
2162+ })
2163+ .when_some(self.overlay_menu.as_ref(), |this, overlay| {
2164+ this.child(
2165+ deferred(
2166+ anchored()
2167+ .position(overlay.position)
2168+ .snap_to_window_with_margin(px(8.))
2169+ .child(overlay.menu.clone()),
2170+ )
2171+ .with_priority(2),
2172+ )
2173+ })
25142174 // The Add project browser floats over everything; a click beside it cancels.
25152175 .when_some(self.folder_browser.as_ref().map(|(browser, _)| browser.clone()), |this, browser| {
25162176 this.child(
+1232−0src/ui/workspace/actions.rsaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+772−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.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+11−0src/worktree.rsView File

Large diffs are not rendered by default.