Vectrum
Sign In

Worktree setup card, like T3 Code's

While a new thread's worktree is made, the conversation shows each stage
as it runs (Fetch base branch, Check out files, Init submodules, Start
agent) with how long it took, Details (branch, base and path), Work
locally (send the message in the project folder instead) and Cancel. A
clean setup leaves once the agent has the message; a failed or cancelled
one stays with what went wrong until the next message.

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

Showing 3 changed files with 341 additions and 32 deletions.

+158−20src/session.rsView File
@@ -21,7 +21,7 @@ use crate::git::{self, TurnChanges};
2121 use crate::models::{Catalog, Provider};
2222 use crate::remote::{ClientMessage, RemoteTask, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient};
2323 use crate::settings::{self, BranchNaming, Streaming};
24- use crate::worktree::{self, PullRequest, Worktree, WorktreePlan};
24+ use crate::worktree::{self, PullRequest, SetupStage, StageStatus, Worktree, WorktreePlan};
2525
2626 const DEFAULT_TITLE: &str = "New thread";
2727 const STDERR_TAIL: usize = 20;
@@ -139,6 +139,58 @@ impl TaskStatus {
139139 }
140140 }
141141
142+ /// How a worktree's setup was called off.
143+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
144+ pub enum SetupCancel {
145+ /// Don't send the message.
146+ Cancel,
147+ /// Send it in the project folder instead.
148+ Local,
149+ }
150+
151+ /// A thread's worktree being made for its first message, stage by stage,
152+ /// for T3 Code's setup card.
153+ pub struct WorktreeSetup {
154+ pub stages: Vec<(SetupStage, StageStatus, Option<Instant>, Option<Instant>)>,
155+ pub started: Instant,
156+ pub base: String,
157+ pub branch: Option<String>,
158+ pub path: Option<PathBuf>,
159+ pub error: Option<String>,
160+ pub cancel: Option<SetupCancel>,
161+ pub cancelled: bool,
162+ }
163+
164+ impl WorktreeSetup {
165+ fn new(plan: &WorktreePlan) -> Self {
166+ Self {
167+ stages: SetupStage::ALL.iter().map(|&stage| (stage, StageStatus::Pending, None, None)).collect(),
168+ started: Instant::now(),
169+ base: plan.base.clone().unwrap_or_else(|| "HEAD".into()),
170+ branch: None,
171+ path: None,
172+ error: None,
173+ cancel: None,
174+ cancelled: false,
175+ }
176+ }
177+
178+ fn mark(&mut self, stage: SetupStage, status: StageStatus) {
179+ let now = Instant::now();
180+ if let Some(entry) = self.stages.iter_mut().find(|entry| entry.0 == stage) {
181+ entry.1 = status;
182+ match status {
183+ StageStatus::Running => entry.2 = Some(now),
184+ StageStatus::Done | StageStatus::Failed => {
185+ entry.2.get_or_insert(now);
186+ entry.3 = Some(now);
187+ }
188+ StageStatus::Pending | StageStatus::Skipped => {}
189+ }
190+ }
191+ }
192+ }
193+
142194 /// A command, monitor or subagent Claude left running in the background. The
143195 /// CLI wakes Claude when it finishes.
144196 pub struct BackgroundTask {
@@ -485,6 +537,9 @@ pub struct Session {
485537 pub interrupted: bool,
486538 /// Creating the worktree before the first message goes out.
487539 pub preparing: bool,
540+ /// The worktree being made for the first message, while it's made (or
541+ /// when its setup failed or was cancelled).
542+ pub worktree_setup: Option<WorktreeSetup>,
488543 /// Tokens in the context window as of the last response.
489544 pub context_tokens: Option<u64>,
490545 pub context_window: Option<u64>,
@@ -593,6 +648,7 @@ impl Session {
593648 queue: Vec::new(),
594649 interrupted: false,
595650 preparing: false,
651+ worktree_setup: None,
596652 context_tokens: None,
597653 context_window: None,
598654 background_tasks: Vec::new(),
@@ -789,6 +845,9 @@ impl Session {
789845 if text.trim().is_empty() && attachments.is_empty() {
790846 return;
791847 }
848+ if !self.preparing {
849+ self.worktree_setup = None;
850+ }
792851 let names: Vec<SharedString> = attachments.iter().map(|path| file_name(path).into()).collect();
793852 let first = !self.items.iter().any(|item| matches!(item, ChatItem::User { .. }));
794853 let now = SystemTime::now();
@@ -812,28 +871,31 @@ impl Session {
812871 self.preparing = true;
813872 self.turn_started = Some(Instant::now());
814873 self.turn_cost = 0.;
815- let task = cx.background_executor().spawn(async move { worktree::create(&plan) });
816- let first_message = text.clone();
874+ self.worktree_setup = Some(WorktreeSetup::new(&plan));
875+ let (progress_tx, progress_rx) = smol::channel::unbounded();
876+ let task = cx.background_executor().spawn(async move {
877+ worktree::create(&plan, &|stage, status| {
878+ let _ = progress_tx.try_send((stage, status));
879+ })
880+ });
817881 cx.spawn(async move |this, cx| {
818- let created = task.await;
819- let _ = this.update(cx, |this, cx| {
820- this.preparing = false;
821- match created {
822- Ok((worktree, cwd)) => {
823- this.cwd = cwd;
824- this.name_branch(&worktree, first_message, cx);
825- this.worktree = Some(worktree);
882+ while let Ok((stage, status)) = progress_rx.recv().await {
883+ let alive = this.update(cx, |this, cx| {
884+ if let Some(setup) = this.worktree_setup.as_mut() {
885+ setup.mark(stage, status);
886+ cx.notify();
826887 }
827- Err(err) => this.push_notice(
828- NoticeKind::Error,
829- format!("Couldn't create a worktree: {err}\n\nThe thread runs in the project folder instead."),
830- ),
888+ });
889+ if alive.is_err() {
890+ return;
831891 }
832- this.start(None, cx);
833- this.snapshot_base(cx);
834- this.deliver(text, attachments, cx);
835- cx.notify();
836- });
892+ }
893+ })
894+ .detach();
895+ let first_message = text.clone();
896+ cx.spawn(async move |this, cx| {
897+ let created = task.await;
898+ let _ = this.update(cx, |this, cx| this.worktree_created(created, first_message, text, attachments, cx));
837899 })
838900 .detach();
839901 cx.notify();
@@ -1073,6 +1135,82 @@ impl Session {
10731135 .detach();
10741136 }
10751137
1138+ /// Takes the worktree the first message asked for, or where its setup
1139+ /// was cancelled or failed, carries on without it; then hands the
1140+ /// message to the agent (unless cancelled).
1141+ fn worktree_created(
1142+ &mut self,
1143+ created: Result<(Worktree, PathBuf), String>,
1144+ first_message: String,
1145+ text: String,
1146+ attachments: Vec<PathBuf>,
1147+ cx: &mut Context<Self>,
1148+ ) {
1149+ self.preparing = false;
1150+ let cancel = self.worktree_setup.as_ref().and_then(|setup| setup.cancel);
1151+ match (created, cancel) {
1152+ (Ok((worktree, _)), Some(_)) => {
1153+ // Cancelled while git was busy: undo it.
1154+ cx.background_executor()
1155+ .spawn(async move {
1156+ let _ = worktree::remove(&worktree);
1157+ let _ = git::git(&worktree.repo, &["branch", "-D", &worktree.branch]);
1158+ })
1159+ .detach();
1160+ }
1161+ (Ok((worktree, cwd)), None) => {
1162+ self.cwd = cwd;
1163+ if let Some(setup) = self.worktree_setup.as_mut() {
1164+ setup.branch = Some(worktree.branch.clone());
1165+ setup.path = Some(worktree.root.clone());
1166+ }
1167+ self.name_branch(&worktree, first_message, cx);
1168+ self.worktree = Some(worktree);
1169+ }
1170+ (Err(err), _) => {
1171+ if let Some(setup) = self.worktree_setup.as_mut() {
1172+ setup.error = Some(err.clone());
1173+ }
1174+ self.push_notice(
1175+ NoticeKind::Error,
1176+ format!("Couldn't create a worktree: {err}\n\nThe thread runs in the project folder instead."),
1177+ );
1178+ }
1179+ }
1180+ if cancel == Some(SetupCancel::Cancel) {
1181+ if let Some(setup) = self.worktree_setup.as_mut() {
1182+ setup.cancelled = true;
1183+ }
1184+ self.status = SessionStatus::Idle;
1185+ self.turn_started = None;
1186+ self.start(None, cx);
1187+ cx.notify();
1188+ return;
1189+ }
1190+ if let Some(setup) = self.worktree_setup.as_mut() {
1191+ setup.mark(SetupStage::Agent, StageStatus::Running);
1192+ }
1193+ self.start(None, cx);
1194+ self.snapshot_base(cx);
1195+ self.deliver(text, attachments, cx);
1196+ // A clean setup leaves the conversation once the agent has it.
1197+ match self.worktree_setup.as_mut() {
1198+ Some(setup) if setup.error.is_none() && setup.cancel.is_none() => self.worktree_setup = None,
1199+ Some(setup) => setup.mark(SetupStage::Agent, StageStatus::Done),
1200+ None => {}
1201+ }
1202+ cx.notify();
1203+ }
1204+
1205+ /// Stops waiting for the worktree: `Cancel` keeps the message unsent,
1206+ /// `Local` sends it in the project folder instead.
1207+ pub fn cancel_worktree_setup(&mut self, mode: SetupCancel, cx: &mut Context<Self>) {
1208+ if let Some(setup) = self.worktree_setup.as_mut().filter(|_| self.preparing) {
1209+ setup.cancel = Some(mode);
1210+ cx.notify();
1211+ }
1212+ }
1213+
10761214 /// Renames a new worktree's temporary branch after the thread's first
10771215 /// message, as Settings → Source Control → Worktree branch naming says.
10781216 /// The folder keeps its name.
+130−1src/ui/chat.rsView File
@@ -30,8 +30,9 @@ use crate::git;
3030 use crate::models::{Catalog, ModelEntry, SlashCommand};
3131 use crate::session::{
3232 ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, SessionEvent,
33- TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary,
33+ SetupCancel, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary,
3434 };
35+ use crate::worktree::StageStatus;
3536 use crate::settings::{self, FollowUp, WorkspaceMode};
3637 use crate::ui::changes::{self, DiffAction, DiffView};
3738 use crate::ui::composer::{self, CONTENT_WIDTH, Density, Tone};
@@ -182,6 +183,8 @@ pub struct ChatView {
182183 /// The composer card's width, measured as it's laid out, so the footer
183184 /// knows how much fits.
184185 composer_width: Pixels,
186+ /// Whether the worktree setup card shows its details.
187+ setup_details: bool,
185188 /// Whether the traits and access controls folded into "…" last frame.
186189 folded: std::cell::Cell<(bool, bool)>,
187190 /// The composer rests on one line while the conversation scrolls.
@@ -305,6 +308,7 @@ impl ChatView {
305308 notices_expanded: false,
306309 tasks_expanded: false,
307310 composer_width: px(0.),
311+ setup_details: false,
308312 folded: Default::default(),
309313 collapsed: false,
310314 _subscriptions: subscriptions,
@@ -1271,6 +1275,9 @@ impl ChatView {
12711275 fn render_status_line(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
12721276 let p = metal::palette(cx);
12731277 let session = self.session.read(cx);
1278+ if session.worktree_setup.is_some() {
1279+ return Some(self.render_worktree_setup(cx));
1280+ }
12741281 if session.is_working() {
12751282 if session.needs_attention() {
12761283 return None;
@@ -1344,6 +1351,128 @@ impl ChatView {
13441351 )
13451352 }
13461353
1354+ /// T3 Code's worktree setup card: the stages of making the thread's
1355+ /// worktree as they run, with Details, Work locally and Cancel; or what
1356+ /// went wrong once it failed or was cancelled.
1357+ fn render_worktree_setup(&self, cx: &mut Context<Self>) -> AnyElement {
1358+ let p = metal::palette(cx);
1359+ let session = self.session.read(cx);
1360+ let Some(setup) = session.worktree_setup.as_ref() else { return div().into_any_element() };
1361+ let running = session.preparing;
1362+ let header = if running {
1363+ "Setting up worktree…"
1364+ } else if setup.cancelled || setup.cancel.is_some() {
1365+ "Worktree setup cancelled"
1366+ } else if setup.error.is_some() {
1367+ "Worktree setup failed"
1368+ } else {
1369+ "Worktree ready"
1370+ };
1371+ let header_color = if setup.error.is_some() && !running { p.red } else { p.ink3 };
1372+ let stage_rows = setup.stages.iter().map(|(stage, status, started, ended)| {
1373+ let (icon, color): (AnyElement, Hsla) = match status {
1374+ StageStatus::Done => (Icon::new(IconName::Check).size(px(14.)).into_any_element(), p.ink3),
1375+ StageStatus::Running => (metal::led(("setup-led", *stage as usize), Led::Live, true, p), p.ink2),
1376+ StageStatus::Failed => (Icon::new(IconName::Close).size(px(14.)).into_any_element(), p.red),
1377+ StageStatus::Skipped => (Icon::new(IconName::Minus).size(px(14.)).into_any_element(), p.ink3),
1378+ StageStatus::Pending => (Icon::new(Lucide::Circle).size(px(14.)).into_any_element(), p.ink3.opacity(0.4)),
1379+ };
1380+ let elapsed = started.map(|started| ended.unwrap_or_else(std::time::Instant::now).duration_since(started).as_secs());
1381+ let trailing = match status {
1382+ StageStatus::Skipped => Some("skipped".to_string()),
1383+ StageStatus::Pending => None,
1384+ _ => elapsed.map(format_elapsed),
1385+ };
1386+ h_flex()
1387+ .h(px(24.))
1388+ .gap(px(10.))
1389+ .text_size(px(13.))
1390+ .text_color(color)
1391+ .child(div().w(px(16.)).flex().justify_center().child(icon))
1392+ .child(div().flex_1().min_w_0().truncate().child(stage.label()))
1393+ .when_some(trailing, |this, trailing| this.child(div().text_size(px(12.)).text_color(p.ink3).child(trailing)))
1394+ });
1395+ let details = self.setup_details.then(|| {
1396+ let row = |label: &'static str, value: String| {
1397+ h_flex()
1398+ .gap(px(12.))
1399+ .child(div().w(px(48.)).text_color(p.ink2).child(label))
1400+ .child(div().flex_1().min_w_0().truncate().font_family(metal::MONO).child(value))
1401+ };
1402+ v_flex()
1403+ .ml(px(26.))
1404+ .mt(px(4.))
1405+ .gap(px(2.))
1406+ .text_size(px(12.))
1407+ .text_color(p.ink3)
1408+ .when_some(setup.branch.clone(), |this, branch| this.child(row("Branch", branch)))
1409+ .child(row("Base", setup.base.clone()))
1410+ .when_some(setup.path.clone(), |this, path| this.child(row("Path", display_path(&path))))
1411+ });
1412+ let (cancel, local) = (self.session.clone(), self.session.clone());
1413+ v_flex()
1414+ .w_full()
1415+ .max_w(px(CONTENT_WIDTH))
1416+ .px(px(16.))
1417+ .pb(px(8.))
1418+ .child(
1419+ h_flex()
1420+ .h(px(24.))
1421+ .px(px(4.))
1422+ .gap(px(8.))
1423+ .mb(px(4.))
1424+ .text_size(px(14.))
1425+ .text_color(header_color)
1426+ .when(running, |this| this.child(metal::led("setup-header-led", Led::Live, true, p)))
1427+ .child(header)
1428+ .child(div().flex_1())
1429+ .child(div().text_size(px(12.)).text_color(p.ink3).child(format_elapsed(setup.started.elapsed().as_secs()))),
1430+ )
1431+ .child(v_flex().px(px(4.)).children(stage_rows))
1432+ .when_some(setup.error.clone().filter(|_| !running), |this, error| {
1433+ this.child(div().ml(px(30.)).mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(error))
1434+ })
1435+ .children(details)
1436+ .child(
1437+ h_flex()
1438+ .ml(px(26.))
1439+ .mt(px(6.))
1440+ .gap(px(4.))
1441+ .child(
1442+ MetalButton::new("setup-details")
1443+ .ghost()
1444+ .mini()
1445+ .label("Details")
1446+ .on_click(cx.listener(|this, _, _, cx| {
1447+ this.setup_details = !this.setup_details;
1448+ cx.notify();
1449+ })),
1450+ )
1451+ .when(running, |this| {
1452+ this.child(
1453+ MetalButton::new("setup-local")
1454+ .ghost()
1455+ .mini()
1456+ .icon(Lucide::Laptop)
1457+ .label("Work locally")
1458+ .on_click(move |_, _, cx| {
1459+ local.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Local, cx))
1460+ }),
1461+ )
1462+ .child(
1463+ MetalButton::new("setup-cancel")
1464+ .ghost()
1465+ .mini()
1466+ .label("Cancel")
1467+ .on_click(move |_, _, cx| {
1468+ cancel.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Cancel, cx))
1469+ }),
1470+ )
1471+ }),
1472+ )
1473+ .into_any_element()
1474+ }
1475+
13471476 /// A composer picker: a trigger that opens `content` above it.
13481477 fn picker(
13491478 &self,
+53−11src/worktree.rsView File
@@ -32,6 +32,37 @@ pub struct Worktree {
3232 pub repo: PathBuf,
3333 }
3434
35+ /// A step of making a thread's worktree, as T3 Code's setup card lists them.
36+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
37+ pub enum SetupStage {
38+ Fetch,
39+ Checkout,
40+ Submodules,
41+ Agent,
42+ }
43+
44+ impl SetupStage {
45+ pub const ALL: [SetupStage; 4] = [SetupStage::Fetch, SetupStage::Checkout, SetupStage::Submodules, SetupStage::Agent];
46+
47+ pub fn label(self) -> &'static str {
48+ match self {
49+ SetupStage::Fetch => "Fetch base branch",
50+ SetupStage::Checkout => "Check out files",
51+ SetupStage::Submodules => "Init submodules",
52+ SetupStage::Agent => "Start agent",
53+ }
54+ }
55+ }
56+
57+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
58+ pub enum StageStatus {
59+ Pending,
60+ Running,
61+ Done,
62+ Failed,
63+ Skipped,
64+ }
65+
3566 /// The prefix of the temporary branch a new worktree starts on, before it's
3667 /// named after the thread's first message.
3768 const TEMP_PREFIX: &str = "vectrum";
@@ -54,7 +85,7 @@ pub fn worktrees_dir() -> Option<PathBuf> {
5485 /// (the project's place inside the new checkout). Like T3 Code, it starts
5586 /// on a temporary `vectrum/<8 hex>` branch in a folder named after it; the
5687 /// branch is renamed once the thread's first message has named it.
57- pub fn create(plan: &WorktreePlan) -> Result<(Worktree, PathBuf), String> {
88+ pub fn create(plan: &WorktreePlan, progress: &dyn Fn(SetupStage, StageStatus)) -> Result<(Worktree, PathBuf), String> {
5889 let repo = PathBuf::from(git(&plan.project, &["rev-parse", "--show-toplevel"])?.trim());
5990 let inside = plan.project.strip_prefix(&repo).map(Path::to_path_buf).unwrap_or_default();
6091 let project = repo.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| "project".into());
@@ -77,19 +108,26 @@ pub fn create(plan: &WorktreePlan) -> Result<(Worktree, PathBuf), String> {
77108
78109 let base = plan.base.clone().unwrap_or_else(|| "HEAD".into());
79110 let mut start = base.clone();
80- if plan.from_origin && base != "HEAD" && !base.starts_with("origin/") {
111+ let has_origin = git(&repo, &["remote"]).is_ok_and(|remotes| remotes.lines().any(|remote| remote == "origin"));
112+ if plan.from_origin && has_origin && base != "HEAD" && !base.starts_with("origin/") {
81113 // The newest commit origin has for the branch, if origin has it.
82- let has_origin = git(&repo, &["remote"]).is_ok_and(|remotes| remotes.lines().any(|remote| remote == "origin"));
83- if has_origin
84- && git(&repo, &["fetch", "--quiet", "origin", &base]).is_ok()
85- && let Ok(sha) = git(&repo, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{base}")])
86- {
114+ progress(SetupStage::Fetch, StageStatus::Running);
115+ let fetched = git(&repo, &["fetch", "--quiet", "origin", &base]).is_ok();
116+ if fetched && let Ok(sha) = git(&repo, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{base}")]) {
87117 start = sha.trim().to_string();
88118 }
119+ progress(SetupStage::Fetch, if fetched { StageStatus::Done } else { StageStatus::Failed });
120+ } else {
121+ progress(SetupStage::Fetch, StageStatus::Skipped);
89122 }
90123 std::fs::create_dir_all(&base_dir).map_err(|err| err.to_string())?;
91124 let root_arg = root.to_string_lossy().to_string();
92- git(&repo, &["worktree", "add", "--no-track", "-b", &branch, &root_arg, &start])?;
125+ progress(SetupStage::Checkout, StageStatus::Running);
126+ if let Err(err) = git(&repo, &["worktree", "add", "--no-track", "-b", &branch, &root_arg, &start]) {
127+ progress(SetupStage::Checkout, StageStatus::Failed);
128+ return Err(err);
129+ }
130+ progress(SetupStage::Checkout, StageStatus::Done);
93131 if base != "HEAD" {
94132 let merge_base = base.strip_prefix("origin/").unwrap_or(&base);
95133 let _ = git(&repo, &["config", &format!("branch.{branch}.gh-merge-base"), merge_base]);
@@ -103,7 +141,11 @@ pub fn create(plan: &WorktreePlan) -> Result<(Worktree, PathBuf), String> {
103141 if !submodules.is_empty() && root.join(".gitmodules").exists() {
104142 // Best effort, as in T3 Code: a submodule that won't fetch doesn't
105143 // stop the thread.
106- let _ = git(&root, submodules);
144+ progress(SetupStage::Submodules, StageStatus::Running);
145+ let fetched = git(&root, submodules).is_ok();
146+ progress(SetupStage::Submodules, if fetched { StageStatus::Done } else { StageStatus::Failed });
147+ } else {
148+ progress(SetupStage::Submodules, StageStatus::Skipped);
107149 }
108150 let cwd = root.join(inside);
109151 Ok((Worktree { root, branch, repo }, cwd))
@@ -242,14 +284,14 @@ mod tests {
242284 from_origin: true,
243285 submodules: Submodules::Recursive,
244286 };
245- let (mut worktree, cwd) = create(&plan).unwrap();
287+ let (mut worktree, cwd) = create(&plan, &|_, _| {}).unwrap();
246288 assert!(worktree.branch.starts_with("vectrum/"), "{}", worktree.branch);
247289 assert!(worktree.root.ends_with(worktree.branch.replace('/', "-")));
248290 assert!(cwd.ends_with("sub"));
249291 assert_eq!(std::fs::read_to_string(cwd.join("file.txt")).unwrap(), "hello\n");
250292
251293 // A second thread gets its own temporary branch.
252- let (mut second, _) = create(&plan).unwrap();
294+ let (mut second, _) = create(&plan, &|_, _| {}).unwrap();
253295 assert_ne!(second.branch, worktree.branch);
254296
255297 // Named after the first message, colliding names are numbered.