Vectrum
Sign In

Make the sidebar resizable and document T3's shortcuts

The sidebar's edge drags between 208px and what leaves the conversation
640px, double-click puts it back, and the width is remembered. List rows
keep their height in long lists, the palette scrolls its highlight into
view, and the README lists the new sidebar, menus, palette and T3 Code's
shortcuts.

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

Showing 7 changed files with 137 additions and 18 deletions.

+45−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 switcher, a way to start without a project, and the branch it starts from.
3454 - A model picker across providers: Claude's models (current ones, plus legacy models
@@ -81,9 +101,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
81101 origin has it, with submodules filled in. Pick Local or New worktree per thread on the
82102 start screen.
83103 - Thread titles written by a small model from the first message (Settings → Text generation).
84- - Pin threads to the top, snooze them until later, rename them (right-click a thread card).
85- The sidebar can group threads by project, fold working threads into a Working section, and
86- order projects by last message, name or date added.
104+ - Pin threads to the top, snooze them until later, rename them, archive them to Settings →
105+ Archive. Working threads can fold into a Working shelf until they need you.
87106 - When a usage limit stops a thread, it can snooze until the limit resets and continue then.
88107 Idle threads, and worktree threads whose pull request merged, can settle on their own.
89108 - Send while the agent works to steer it, or queue the message for after the turn (and
@@ -138,16 +157,29 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
138157 cargo run --release -- /path/to/project # defaults to the current directory
139158 ```
140159
160+ The shortcuts follow T3 Code's default keymap.
161+
141162 | Shortcut | Action |
142163 | --- | --- |
143164 | Enter / Shift+Enter | Send / new line |
144165 | Esc | Interrupt Claude |
145- | Ctrl+N | New thread in the current project |
166+ | Ctrl+K | Command palette |
167+ | Ctrl+N (or Ctrl+Shift+O) | New thread (picks the project when there are several) |
168+ | Ctrl+Shift+N | New thread in the current project |
169+ | Ctrl+Alt+N | New thread without a project |
170+ | Ctrl+Shift+[ / Ctrl+Shift+] | Previous / next thread |
171+ | Ctrl+1–9 | Jump to a sidebar thread (hold Ctrl to see the numbers) |
172+ | Ctrl+Shift+S | Settle the thread, or un-settle it |
173+ | Ctrl+Shift+P | Pin or unpin the thread |
174+ | Ctrl+Z | Undo the last settle, snooze, unpin or archive (while the notice shows) |
175+ | Ctrl+Shift+C | Copy the thread's PR link, or its ID |
176+ | Ctrl+U | Usage |
177+ | Ctrl+Alt+A / Ctrl+Alt+Shift+A | Change the theme / cycle System, Light and Dark |
178+ | Ctrl+B | Show or hide the sidebar |
179+ | Ctrl+Alt+B | Show or hide the right panel (the browser) |
180+ | Ctrl+Shift+B | Show or hide the thread details (project panel) |
146181 | Ctrl+O | Add a project folder |
147- | Ctrl+K | Search threads |
148182 | Ctrl+, | Open or close settings |
149- | Ctrl+B | Show or hide the sidebar |
150- | Ctrl+Shift+B | Show or hide the project panel |
151183 | Ctrl+Q | Quit |
152184
153185 ### Environment
@@ -181,7 +213,9 @@ in its `scratch` folder.
181213 - `src/ui/settings.rs`: the settings view and its pages.
182214 - `src/ui/changes.rs`: the per-turn change overview and the diff view.
183215 - `src/ui/workspace.rs`: window root: thread sidebar, top bar and project panel, plus the
184- git and editor helpers behind it.
216+ git and editor helpers behind it. Its submodules hold the sidebar (`sidebar.rs`, and the
217+ legacy layout in `sidebar/legacy.rs`), the thread menus, snoozing, confirmations and undo
218+ (`actions.rs`), the chat header (`header.rs`) and the command palette (`palette.rs`).
185219 - `src/ui/icons.rs`: the extra Lucide icons the app embeds.
186220 - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from
187221 MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells,
+2−0src/store.rsView File
@@ -32,6 +32,8 @@ pub struct AppState {
3232 pub settled_expanded: bool,
3333 /// Projects folded away in the legacy sidebar.
3434 pub collapsed_projects: Vec<PathBuf>,
35+ /// The thread sidebar's width as last dragged; `None` is the default.
36+ pub sidebar_width: Option<f32>,
3537 }
3638
3739 fn threads_dir() -> Option<PathBuf> {
+27−0src/ui/workspace.rsView File
@@ -55,6 +55,10 @@ use palette::CommandPalette;
5555 use remote::RemoteState;
5656
5757 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.;
5862 const PANEL_WIDTH: f32 = 292.;
5963 /// Below this window width the project panel stays out of the conversation's way.
6064 const PANEL_MIN_WINDOW: f32 = 1120.;
@@ -232,6 +236,9 @@ pub struct Workspace {
232236 jump_hints: bool,
233237 /// A title click waiting to see whether it becomes a double-click.
234238 title_click: Task<()>,
239+ /// The thread sidebar's width, and whether its edge is being dragged.
240+ sidebar_width: f32,
241+ resizing_sidebar: bool,
235242 editors: Rc<Vec<EditorApp>>,
236243 editor: usize,
237244 hostname: SharedString,
@@ -359,6 +366,8 @@ impl Workspace {
359366 ctrl_held: None,
360367 jump_hints: false,
361368 title_click: Task::ready(()),
369+ sidebar_width: SIDEBAR_WIDTH,
370+ resizing_sidebar: false,
362371 editors: Rc::new(editors),
363372 editor,
364373 hostname: hostname().into(),
@@ -416,6 +425,7 @@ impl Workspace {
416425 self.snoozed_open = state.snoozed_expanded;
417426 self.settled_open = state.settled_expanded;
418427 self.collapsed_projects = state.collapsed_projects.iter().cloned().collect();
428+ self.sidebar_width = state.sidebar_width.unwrap_or(SIDEBAR_WIDTH).max(SIDEBAR_MIN_WIDTH);
419429 for project in &state.projects {
420430 let name = Some(project.name.as_str()).filter(|name| !name.is_empty());
421431 let ix = self.ensure_project(project.path.clone(), name);
@@ -470,6 +480,7 @@ impl Workspace {
470480 paths.sort();
471481 paths
472482 },
483+ sidebar_width: Some(self.sidebar_width.round()).filter(|width| *width != SIDEBAR_WIDTH),
473484 }
474485 }
475486
@@ -2003,6 +2014,22 @@ impl Render for Workspace {
20032014 }))
20042015 .on_action(cx.listener(|this, _: &CycleAppearance, window, cx| this.cycle_appearance(window, cx)))
20052016 .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.jump_with_key(event, window, cx)))
2017+ // Dragging the sidebar's edge.
2018+ .when(self.resizing_sidebar, |this| {
2019+ this.cursor_col_resize()
2020+ .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
2021+ let max = (window.viewport_size().width.as_f32() - MAIN_MIN_WIDTH).max(SIDEBAR_MIN_WIDTH);
2022+ this.sidebar_width = event.position.x.as_f32().clamp(SIDEBAR_MIN_WIDTH, max);
2023+ cx.notify();
2024+ }))
2025+ .on_mouse_up(
2026+ MouseButton::Left,
2027+ cx.listener(|this, _, _, cx| {
2028+ this.resizing_sidebar = false;
2029+ cx.notify();
2030+ }),
2031+ )
2032+ })
20062033 .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| {
20072034 this.modifiers_changed(event, window, cx)
20082035 }))
+3−1src/ui/workspace/header.rsView File
@@ -158,7 +158,9 @@ impl Workspace {
158158 .font_weight(FontWeight::MEDIUM)
159159 .text_color(p.ink)
160160 .hover(|style| style.bg(p.row_hover))
161- .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
161+ .when(self.overlay_menu.is_none(), |this| {
162+ this.tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
163+ })
162164 .on_click(cx.listener(|this, event: &ClickEvent, window, cx| this.click_title(event, window, cx)))
163165 .child(div().min_w_0().truncate().child(title))
164166 .child(
+12−4src/ui/workspace/palette.rsView File
@@ -560,12 +560,18 @@ impl CommandPalette {
560560 }
561561
562562 fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
563- let count = self.results(cx).len();
564- if count == 0 {
563+ let results = self.results(cx);
564+ if results.is_empty() {
565565 return;
566566 }
567- self.highlight = (self.highlight as isize + delta).rem_euclid(count as isize) as usize;
568- self.scroll.scroll_to_item(self.highlight);
567+ self.highlight = (self.highlight as isize + delta).rem_euclid(results.len() as isize) as usize;
568+ // The list's children include a heading per group.
569+ let headings = results[..=self.highlight]
570+ .iter()
571+ .enumerate()
572+ .filter(|(ix, item)| *ix == 0 || results[ix - 1].group != item.group)
573+ .count();
574+ self.scroll.scroll_to_item(self.highlight + headings);
569575 cx.notify();
570576 }
571577
@@ -632,6 +638,7 @@ impl Render for CommandPalette {
632638 last_group = Some(item.group);
633639 rows.push(
634640 div()
641+ .flex_none()
635642 .px(px(10.))
636643 .pt(px(if ix == 0 { 4. } else { 10. }))
637644 .pb(px(4.))
@@ -646,6 +653,7 @@ impl Render for CommandPalette {
646653 rows.push(
647654 h_flex()
648655 .id(("palette-row", ix))
656+ .flex_none()
649657 .min_h(px(36.))
650658 .px(px(10.))
651659 .py(px(6.))
+46−2src/ui/workspace/sidebar.rsView File
@@ -371,7 +371,7 @@ impl Workspace {
371371 pub(super) fn render_sidebar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
372372 let legacy = self.legacy_sidebar();
373373 v_flex()
374- .w(px(super::SIDEBAR_WIDTH))
374+ .w(px(self.sidebar_width))
375375 .h_full()
376376 .flex_none()
377377 .relative()
@@ -384,6 +384,45 @@ impl Workspace {
384384 }
385385 })
386386 .child(self.render_sidebar_footer(cx))
387+ .child(self.render_resize_rail(cx))
388+ }
389+
390+ /// The sidebar's right edge: drag to resize, double-click for the
391+ /// default width.
392+ fn render_resize_rail(&self, cx: &mut Context<Self>) -> impl IntoElement {
393+ let p = metal::palette(cx);
394+ let resizing = self.resizing_sidebar;
395+ div()
396+ .id("sidebar-rail")
397+ .absolute()
398+ .top_0()
399+ .bottom_0()
400+ .right(px(-8.))
401+ .w(px(16.))
402+ .flex()
403+ .justify_center()
404+ .cursor_col_resize()
405+ .group("sidebar-rail")
406+ .tooltip(|window, cx| Tooltip::new("Drag to resize sidebar").build(window, cx))
407+ .on_mouse_down(
408+ MouseButton::Left,
409+ cx.listener(|this, event: &MouseDownEvent, _, cx| {
410+ if event.click_count >= 2 {
411+ this.sidebar_width = super::SIDEBAR_WIDTH;
412+ } else {
413+ this.resizing_sidebar = true;
414+ }
415+ cx.stop_propagation();
416+ cx.notify();
417+ }),
418+ )
419+ .child(
420+ div()
421+ .w(px(2.))
422+ .h_full()
423+ .when(!resizing, |this| this.invisible().group_hover("sidebar-rail", |style| style.visible()))
424+ .bg(p.rule),
425+ )
387426 }
388427
389428 /// The brand row, with the sidebar toggle.
@@ -702,7 +741,7 @@ impl Workspace {
702741 this.scope_picker = None;
703742 cx.notify();
704743 }))
705- .w(px(super::SIDEBAR_WIDTH - 16.))
744+ .w(px(self.sidebar_width - 16.))
706745 .max_w(px(288.))
707746 .child(
708747 h_flex()
@@ -801,6 +840,7 @@ impl Workspace {
801840 children.push(
802841 h_flex()
803842 .id("settled-more")
843+ .flex_none()
804844 .h(px(SLIM_HEIGHT))
805845 .px(px(10.))
806846 .gap(px(10.))
@@ -1297,6 +1337,7 @@ impl Workspace {
12971337 .child(line_three)
12981338 .children(Self::jump_pill(jump, show_hints, p));
12991339 div()
1340+ .flex_none()
13001341 .py(px(1.))
13011342 .child(surface.context_menu(self.row_menu(session, cx)))
13021343 .into_any_element()
@@ -1394,6 +1435,7 @@ impl Workspace {
13941435 .id(("thread", key))
13951436 .group(group.clone())
13961437 .relative()
1438+ .flex_none()
13971439 .h(px(SLIM_HEIGHT))
13981440 .px(px(10.))
13991441 .gap(px(10.))
@@ -1460,6 +1502,7 @@ impl Workspace {
14601502 let workspace = cx.weak_entity();
14611503 let menu_session = session.clone();
14621504 h_flex()
1505+ .flex_none()
14631506 .py(px(1.))
14641507 .child(
14651508 v_flex()
@@ -1544,6 +1587,7 @@ impl Workspace {
15441587 let highlighted = row == highlight;
15451588 h_flex()
15461589 .id(("search-hit", row))
1590+ .flex_none()
15471591 .min_h(px(36.))
15481592 .px(px(10.))
15491593 .py(px(4.))
+2−0src/ui/workspace/sidebar/legacy.rsView File
@@ -407,6 +407,7 @@ impl Workspace {
407407 h_flex()
408408 .id(("legacy-group", group_ix))
409409 .group(group.clone())
410+ .flex_none()
410411 .mt(px(4.))
411412 .h(px(32.))
412413 .pl(px(8.))
@@ -533,6 +534,7 @@ impl Workspace {
533534 .id(("legacy-thread", key))
534535 .group(group.clone())
535536 .relative()
537+ .flex_none()
536538 .h(px(32.))
537539 .pl(px(30.))
538540 .pr(px(8.))