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>
Showing 3 changed files with 78 additions and 4 deletions.
| @@ -491,6 +491,9 @@ pub struct Session { | ||
| 491 | 491 | /// The worktree to create when the first message is sent. |
| 492 | 492 | pub pending_worktree: Option<WorktreePlan>, |
| 493 | 493 | 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>, | |
| 494 | 497 | /// Set for a thread mirrored from another device. |
| 495 | 498 | pub remote: Option<RemoteLink>, |
| 496 | 499 | pub items: Vec<ChatItem>, |
| @@ -621,6 +624,7 @@ impl Session { | ||
| 621 | 624 | worktree: None, |
| 622 | 625 | pending_worktree: worktree, |
| 623 | 626 | pull_request: None, |
| 627 | + branch: None, | |
| 624 | 628 | remote: None, |
| 625 | 629 | items: Vec::new(), |
| 626 | 630 | status: SessionStatus::Starting, |
| @@ -2978,6 +2982,9 @@ pub struct SavedThread { | ||
| 2978 | 2982 | pub pending_worktree: Option<WorktreePlan>, |
| 2979 | 2983 | #[serde(default)] |
| 2980 | 2984 | 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>, | |
| 2981 | 2988 | pub resume_id: Option<String>, |
| 2982 | 2989 | pub model: Option<String>, |
| 2983 | 2990 | pub active_model: Option<String>, |
| @@ -3136,6 +3143,7 @@ impl Session { | ||
| 3136 | 3143 | worktree: self.worktree.clone(), |
| 3137 | 3144 | pending_worktree: self.pending_worktree.clone(), |
| 3138 | 3145 | pull_request: self.pull_request.clone(), |
| 3146 | + branch: self.branch.clone(), | |
| 3139 | 3147 | resume_id: self.resume_id.clone(), |
| 3140 | 3148 | model: self.model.clone(), |
| 3141 | 3149 | active_model: self.active_model.clone(), |
| @@ -3180,6 +3188,7 @@ impl Session { | ||
| 3180 | 3188 | session.cwd = saved.cwd; |
| 3181 | 3189 | session.worktree = saved.worktree; |
| 3182 | 3190 | session.pull_request = saved.pull_request; |
| 3191 | + session.branch = saved.branch; | |
| 3183 | 3192 | session.resume_id = saved.resume_id; |
| 3184 | 3193 | session.active_model = saved.active_model; |
| 3185 | 3194 | session.total_cost = saved.total_cost; |
| @@ -92,12 +92,15 @@ pub(crate) enum Notice { | ||
| 92 | 92 | BackgroundWork { title: SharedString, detail: Option<SharedString>, waiting: bool, task_ids: Vec<String> }, |
| 93 | 93 | /// A usage limit stopped the thread. |
| 94 | 94 | 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 }, | |
| 95 | 98 | } |
| 96 | 99 | |
| 97 | 100 | impl Notice { |
| 98 | 101 | fn tone(&self) -> Tone { |
| 99 | 102 | match self { |
| 100 | - Notice::BackgroundWork { .. } => Tone::Neutral, | |
| 103 | + Notice::BackgroundWork { .. } | Notice::BranchChanged { .. } => Tone::Neutral, | |
| 101 | 104 | Notice::Limit { .. } => Tone::Warning, |
| 102 | 105 | } |
| 103 | 106 | } |
| @@ -440,7 +443,11 @@ impl ChatView { | ||
| 440 | 443 | return; |
| 441 | 444 | } |
| 442 | 445 | let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue; |
| 446 | + let branch = self.checkout_branch(cx); | |
| 443 | 447 | self.session.update(cx, |session, cx| { |
| 448 | + if branch.is_some() { | |
| 449 | + session.branch = branch; | |
| 450 | + } | |
| 444 | 451 | if (session.is_working() || session.preparing) && queue_mode != opposite { |
| 445 | 452 | session.queue_message(text, attachments, cx); |
| 446 | 453 | } else { |
| @@ -725,9 +732,28 @@ impl ChatView { | ||
| 725 | 732 | if let Some(resets) = session.limited_until() { |
| 726 | 733 | notices.push(Notice::Limit { resets, resume_at: session.resume_at }); |
| 727 | 734 | } |
| 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 | + } | |
| 728 | 744 | notices |
| 729 | 745 | } |
| 730 | 746 | |
| 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 | + | |
| 731 | 757 | // ---- the `/`, `$` and `@` menus --------------------------------------- |
| 732 | 758 | |
| 733 | 759 | /// The menu for the word at the cursor: `/` at the start of a line lists |
| @@ -418,7 +418,7 @@ impl ChatView { | ||
| 418 | 418 | }); |
| 419 | 419 | let stacked = expanded.then(|| { |
| 420 | 420 | 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) | |
| 422 | 422 | .rounded(px(16.)) |
| 423 | 423 | .px(px(4.)) |
| 424 | 424 | .py(px(5.)) |
| @@ -432,7 +432,7 @@ impl ChatView { | ||
| 432 | 432 | .children(peek.filter(|_| !expanded)) |
| 433 | 433 | .child( |
| 434 | 434 | 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)), | |
| 436 | 436 | ) |
| 437 | 437 | .into_any_element() |
| 438 | 438 | } |
| @@ -665,7 +665,7 @@ impl ChatView { | ||
| 665 | 665 | } |
| 666 | 666 | |
| 667 | 667 | /// 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> { | |
| 669 | 669 | let session = session.clone(); |
| 670 | 670 | match notice { |
| 671 | 671 | Notice::BackgroundWork { title, detail, waiting, task_ids } => { |
| @@ -691,6 +691,45 @@ fn render_notice(session: &Entity<Session>, id: impl Into<ElementId>, notice: &N | ||
| 691 | 691 | }) |
| 692 | 692 | })) |
| 693 | 693 | } |
| 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 | + } | |
| 694 | 733 | Notice::Limit { resets, resume_at } => { |
| 695 | 734 | let resets = *resets; |
| 696 | 735 | composer::row() |