Vectrum
Sign In

Branch changed notice, like T3 Code's

A thread that runs in the project's checkout remembers the branch it ran
on. When the checkout has moved to another branch since, typing a message
docks "Branch changed — was <branch>" on the composer, with Restore branch
and a way to dismiss it.

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

Showing 3 changed files with 78 additions and 4 deletions.

+9−0src/session.rsView File
@@ -491,6 +491,9 @@ pub struct Session {
491491 /// The worktree to create when the first message is sent.
492492 pub pending_worktree: Option<WorktreePlan>,
493493 pub pull_request: Option<PullRequest>,
494+ /// The branch the thread last ran on, when it runs in the project's
495+ /// checkout (not a worktree of its own).
496+ pub branch: Option<String>,
494497 /// Set for a thread mirrored from another device.
495498 pub remote: Option<RemoteLink>,
496499 pub items: Vec<ChatItem>,
@@ -621,6 +624,7 @@ impl Session {
621624 worktree: None,
622625 pending_worktree: worktree,
623626 pull_request: None,
627+ branch: None,
624628 remote: None,
625629 items: Vec::new(),
626630 status: SessionStatus::Starting,
@@ -2978,6 +2982,9 @@ pub struct SavedThread {
29782982 pub pending_worktree: Option<WorktreePlan>,
29792983 #[serde(default)]
29802984 pub pull_request: Option<PullRequest>,
2985+ /// The branch the thread last ran on, in the project's checkout.
2986+ #[serde(default)]
2987+ pub branch: Option<String>,
29812988 pub resume_id: Option<String>,
29822989 pub model: Option<String>,
29832990 pub active_model: Option<String>,
@@ -3136,6 +3143,7 @@ impl Session {
31363143 worktree: self.worktree.clone(),
31373144 pending_worktree: self.pending_worktree.clone(),
31383145 pull_request: self.pull_request.clone(),
3146+ branch: self.branch.clone(),
31393147 resume_id: self.resume_id.clone(),
31403148 model: self.model.clone(),
31413149 active_model: self.active_model.clone(),
@@ -3180,6 +3188,7 @@ impl Session {
31803188 session.cwd = saved.cwd;
31813189 session.worktree = saved.worktree;
31823190 session.pull_request = saved.pull_request;
3191+ session.branch = saved.branch;
31833192 session.resume_id = saved.resume_id;
31843193 session.active_model = saved.active_model;
31853194 session.total_cost = saved.total_cost;
+27−1src/ui/chat.rsView File
@@ -92,12 +92,15 @@ pub(crate) enum Notice {
9292 BackgroundWork { title: SharedString, detail: Option<SharedString>, waiting: bool, task_ids: Vec<String> },
9393 /// A usage limit stopped the thread.
9494 Limit { resets: SystemTime, resume_at: Option<SystemTime> },
95+ /// The project's checkout moved to another branch since the thread
96+ /// last ran there.
97+ BranchChanged { was: String, now: String },
9598 }
9699
97100 impl Notice {
98101 fn tone(&self) -> Tone {
99102 match self {
100- Notice::BackgroundWork { .. } => Tone::Neutral,
103+ Notice::BackgroundWork { .. } | Notice::BranchChanged { .. } => Tone::Neutral,
101104 Notice::Limit { .. } => Tone::Warning,
102105 }
103106 }
@@ -440,7 +443,11 @@ impl ChatView {
440443 return;
441444 }
442445 let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
446+ let branch = self.checkout_branch(cx);
443447 self.session.update(cx, |session, cx| {
448+ if branch.is_some() {
449+ session.branch = branch;
450+ }
444451 if (session.is_working() || session.preparing) && queue_mode != opposite {
445452 session.queue_message(text, attachments, cx);
446453 } else {
@@ -725,9 +732,28 @@ impl ChatView {
725732 if let Some(resets) = session.limited_until() {
726733 notices.push(Notice::Limit { resets, resume_at: session.resume_at });
727734 }
735+ // Shown once there's something to send, as in T3 Code.
736+ let typing = !self.composer.read(cx).value().trim().is_empty();
737+ if typing
738+ && let Some(was) = session.branch.clone()
739+ && let Some(now) = self.checkout_branch(cx)
740+ && was != now
741+ {
742+ notices.push(Notice::BranchChanged { was, now });
743+ }
728744 notices
729745 }
730746
747+ /// The branch the project's checkout is on, for a thread that runs in
748+ /// it (not in a worktree of its own, nor on another device).
749+ fn checkout_branch(&self, cx: &App) -> Option<String> {
750+ let session = self.session.read(cx);
751+ if session.worktree.is_some() || session.pending_worktree.is_some() || session.is_remote() {
752+ return None;
753+ }
754+ cx.try_global::<ProjectDirectory>()?.find(&session.project, None)?.branch.clone()
755+ }
756+
731757 // ---- the `/`, `$` and `@` menus ---------------------------------------
732758
733759 /// The menu for the word at the cursor: `/` at the start of a line lists
+42−3src/ui/chat/dock.rsView File
@@ -418,7 +418,7 @@ impl ChatView {
418418 });
419419 let stacked = expanded.then(|| {
420420 v_flex().gap(px(8.)).pb(px(8.)).children(rest.iter().enumerate().map(|(ix, notice)| {
421- render_notice(&self.session, ("notice-stacked", ix), notice, p)
421+ render_notice(&self.session, chat.clone(), ("notice-stacked", ix), notice, p)
422422 .rounded(px(16.))
423423 .px(px(4.))
424424 .py(px(5.))
@@ -432,7 +432,7 @@ impl ChatView {
432432 .children(peek.filter(|_| !expanded))
433433 .child(
434434 composer::banner(front.tone(), Density::Comfortable, joined, p)
435- .child(render_notice(&self.session, ("notice-front", 0usize), &front, p)),
435+ .child(render_notice(&self.session, chat.clone(), ("notice-front", 0usize), &front, p)),
436436 )
437437 .into_any_element()
438438 }
@@ -665,7 +665,7 @@ impl ChatView {
665665 }
666666
667667 /// One notice's row.
668- fn render_notice(session: &Entity<Session>, id: impl Into<ElementId>, notice: &Notice, p: &Palette) -> Stateful<Div> {
668+ fn render_notice(session: &Entity<Session>, chat: WeakEntity<ChatView>, id: impl Into<ElementId>, notice: &Notice, p: &Palette) -> Stateful<Div> {
669669 let session = session.clone();
670670 match notice {
671671 Notice::BackgroundWork { title, detail, waiting, task_ids } => {
@@ -691,6 +691,45 @@ fn render_notice(session: &Entity<Session>, id: impl Into<ElementId>, notice: &N
691691 })
692692 }))
693693 }
694+ Notice::BranchChanged { was, now } => {
695+ let (restore, dismiss) = (was.clone(), now.clone());
696+ let tooltip: SharedString = format!("This thread last ran on {was}. Sending will continue on {now}.").into();
697+ composer::row()
698+ .id(id)
699+ .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
700+ .child(composer::icon_cell(Some(Icon::new(Lucide::GitBranch).size(px(12.))), p))
701+ .child(
702+ composer::content()
703+ .child(div().flex_none().font_weight(FontWeight::MEDIUM).child("Branch changed — was"))
704+ .child(div().min_w_0().truncate().font_family(metal::MONO).text_color(p.ink2).child(was.clone())),
705+ )
706+ .child(
707+ MetalButton::new("restore-branch").ghost().mini().h(px(24.)).label("Restore branch").on_click({
708+ let session = session.clone();
709+ move |_, _, cx| {
710+ let restore = restore.clone();
711+ session.update(cx, |session, cx| {
712+ session.branch = None;
713+ cx.notify();
714+ });
715+ if let Some(chat) = chat.upgrade() {
716+ chat.update(cx, |_, cx| cx.emit(super::ChatEvent::SwitchBranch { branch: restore, create: false }));
717+ }
718+ }
719+ }),
720+ )
721+ .child(
722+ composer::icon_button("dismiss-branch", IconName::Close, "Dismiss branch change notice").on_click(
723+ move |_, _, cx| {
724+ let now = dismiss.clone();
725+ session.update(cx, |session, cx| {
726+ session.branch = Some(now);
727+ cx.notify();
728+ })
729+ },
730+ ),
731+ )
732+ }
694733 Notice::Limit { resets, resume_at } => {
695734 let resets = *resets;
696735 composer::row()