authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 16:17:07-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 18:53:21-08:00
logde61c0c42b0797735a5cbe7ec6d4b3358fcf4295
treef74abd294eb0468d9ec8b7359a3db49ebf80d2ec
parentecb4a1efa43ae762b4314895505371033170faae

Guard worktree branch rename and fix branch toolbar state

- Skip auto-rename when the active worktree branch no longer matches the temp branch - Update reactor tests to cover branch-switch and first-turn rename behavior - Show the actual current branch in the toolbar and use a VS Code icon in Open In picker

5 files changed, 231 insertions(+), 76 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+75-12
......@@ -108,11 +108,27 @@ describe("ProviderCommandReactor", () => {
108108 const interruptTurn = vi.fn((_: unknown) => Effect.void);
109109 const respondToRequest = vi.fn((_: unknown) => Effect.void);
110110 const stopSession = vi.fn((_: unknown) => Effect.void);
111 const statusBranchByCwd = new Map<string, string | null>();
111112 const renameBranch = vi.fn((_: unknown) =>
112113 Effect.succeed({
113114 branch: "t3code/generated-name",
114115 }),
115116 );
117 const statusDetails = vi.fn((cwd: string) =>
118 Effect.succeed({
119 branch: statusBranchByCwd.get(cwd) ?? null,
120 upstreamRef: null,
121 hasWorkingTreeChanges: false,
122 workingTree: {
123 files: [],
124 insertions: 0,
125 deletions: 0,
126 },
127 hasUpstream: false,
128 aheadCount: 0,
129 behindCount: 0,
130 }),
131 );
116132 const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() =>
117133 Effect.succeed({
118134 branch: "generated-name",
......@@ -141,7 +157,12 @@ describe("ProviderCommandReactor", () => {
141157 const layer = ProviderCommandReactorLive.pipe(
142158 Layer.provideMerge(orchestrationLayer),
143159 Layer.provideMerge(Layer.succeed(ProviderService, service)),
144 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
160 Layer.provideMerge(
161 Layer.succeed(
162 GitCore,
163 { renameBranch, statusDetails } as unknown as GitCoreShape,
164 ),
165 ),
145166 Layer.provideMerge(
146167 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
147168 ),
......@@ -189,7 +210,11 @@ describe("ProviderCommandReactor", () => {
189210 respondToRequest,
190211 stopSession,
191212 renameBranch,
213 statusDetails,
192214 generateBranchName,
215 setStatusBranch: (cwd: string, branch: string | null) => {
216 statusBranchByCwd.set(cwd, branch);
217 },
193218 stateDir,
194219 };
195220 }
......@@ -235,6 +260,7 @@ describe("ProviderCommandReactor", () => {
235260 it("generates and renames temporary worktree branch on first turn", async () => {
236261 const harness = await createHarness();
237262 const now = new Date().toISOString();
263 const worktreeCwd = "/tmp/provider-project/.t3/worktrees/t3code-89abc123";
238264
239265 await Effect.runPromise(
240266 harness.engine.dispatch({
......@@ -242,9 +268,10 @@ describe("ProviderCommandReactor", () => {
242268 commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"),
243269 threadId: ThreadId.makeUnsafe("thread-1"),
244270 branch: "t3code/89abc123",
245 worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
271 worktreePath: worktreeCwd,
246272 }),
247273 );
274 harness.setStatusBranch(worktreeCwd, "t3code/89abc123");
248275
249276 await Effect.runPromise(
250277 harness.engine.dispatch({
......@@ -275,7 +302,7 @@ describe("ProviderCommandReactor", () => {
275302 await waitFor(() => harness.renameBranch.mock.calls.length === 1);
276303
277304 expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({
278 cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
305 cwd: worktreeCwd,
279306 message: "Fix visual bug from screenshot",
280307 attachments: [
281308 {
......@@ -288,18 +315,14 @@ describe("ProviderCommandReactor", () => {
288315 ],
289316 });
290317 expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({
291 cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
318 cwd: worktreeCwd,
292319 oldBranch: "t3code/89abc123",
293320 newBranch: "t3code/generated-name",
294321 });
295322
296 await waitFor(() => {
297 const readModel = Effect.runSync(harness.engine.getReadModel());
298 const thread = readModel.threads.find(
299 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
300 );
301 return thread?.branch === "t3code/generated-name";
302 });
323 const readModel = await Effect.runPromise(harness.engine.getReadModel());
324 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
325 expect(thread?.branch).toBe("t3code/89abc123");
303326 });
304327
305328 it("passes persisted attachment references to branch generation and turn start", async () => {
......@@ -373,6 +396,7 @@ describe("ProviderCommandReactor", () => {
373396 it("skips worktree branch generation after the first user turn", async () => {
374397 const harness = await createHarness();
375398 const now = new Date().toISOString();
399 const worktreeCwd = "/tmp/provider-project/.t3/worktrees/t3code-1234abcd";
376400
377401 await Effect.runPromise(
378402 harness.engine.dispatch({
......@@ -380,9 +404,10 @@ describe("ProviderCommandReactor", () => {
380404 commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"),
381405 threadId: ThreadId.makeUnsafe("thread-1"),
382406 branch: "t3code/1234abcd",
383 worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd",
407 worktreePath: worktreeCwd,
384408 }),
385409 );
410 harness.setStatusBranch(worktreeCwd, "t3code/1234abcd");
386411
387412 await Effect.runPromise(
388413 harness.engine.dispatch({
......@@ -425,6 +450,44 @@ describe("ProviderCommandReactor", () => {
425450 expect(harness.renameBranch.mock.calls.length).toBe(1);
426451 });
427452
453 it("skips worktree rename when active branch already changed", async () => {
454 const harness = await createHarness();
455 const now = new Date().toISOString();
456 const worktreeCwd = "/tmp/provider-project/.t3/worktrees/t3code-badf00d0";
457
458 await Effect.runPromise(
459 harness.engine.dispatch({
460 type: "thread.meta.update",
461 commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-switched"),
462 threadId: ThreadId.makeUnsafe("thread-1"),
463 branch: "t3code/badf00d0",
464 worktreePath: worktreeCwd,
465 }),
466 );
467 harness.setStatusBranch(worktreeCwd, "feature/add-vscode-open-in-support");
468
469 await Effect.runPromise(
470 harness.engine.dispatch({
471 type: "thread.turn.start",
472 commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-switched"),
473 threadId: ThreadId.makeUnsafe("thread-1"),
474 message: {
475 messageId: asMessageId("user-message-worktree-switched"),
476 role: "user",
477 text: "Fix visual bug from screenshot",
478 attachments: [],
479 },
480 approvalPolicy: "on-request",
481 sandboxMode: "workspace-write",
482 createdAt: now,
483 }),
484 );
485
486 await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
487 await Effect.runPromise(Effect.sleep("20 millis"));
488 expect(harness.renameBranch.mock.calls.length).toBe(0);
489 });
490
428491 it("skips worktree rename when branch-name generation fails", async () => {
429492 const harness = await createHarness();
430493 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+36-11
......@@ -330,17 +330,42 @@ const make = Effect.gen(function* () {
330330 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
331331 if (targetBranch === oldBranch) return Effect.void;
332332
333 return Effect.flatMap(
334 git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }),
335 (renamed) =>
336 orchestrationEngine.dispatch({
337 type: "thread.meta.update",
338 commandId: serverCommandId("worktree-branch-rename"),
339 threadId: input.threadId,
340 branch: renamed.branch,
341 worktreePath: cwd,
342 }),
343 );
333 return Effect.gen(function* () {
334 const currentBranch = yield* git.statusDetails(cwd).pipe(
335 Effect.map((status) => status.branch),
336 Effect.catch((error) =>
337 Effect.logWarning(
338 "provider command reactor failed to read active worktree branch",
339 {
340 threadId: input.threadId,
341 cwd,
342 oldBranch,
343 targetBranch,
344 reason: error.message,
345 },
346 ).pipe(Effect.as(null)),
347 ),
348 );
349
350 if (currentBranch !== oldBranch) {
351 return yield* Effect.logWarning(
352 "provider command reactor skipped worktree branch rename because active branch changed",
353 {
354 threadId: input.threadId,
355 cwd,
356 oldBranch,
357 targetBranch,
358 currentBranch,
359 },
360 );
361 }
362
363 yield* git.renameBranch({
364 cwd,
365 oldBranch,
366 newBranch: targetBranch,
367 });
368 });
344369 }),
345370 Effect.catchCause((cause) =>
346371 Effect.logWarning(
apps/web/src/components/BranchToolbar.tsx+11-51
......@@ -1,6 +1,6 @@
11import type { GitBranch, ThreadId } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useState } from "react";
3import { useState } from "react";
44
55import { newCommandId } from "../lib/utils";
66import {
......@@ -14,7 +14,6 @@ import { useStore } from "../store";
1414import {
1515 dedupeRemoteBranchesWithLocalMatches,
1616 deriveLocalBranchNameFromRemoteRef,
17 deriveSyncedLocalBranch,
1817} from "./BranchToolbar.logic";
1918import { Button } from "./ui/button";
2019import {
......@@ -72,6 +71,11 @@ export default function BranchToolbar({
7271 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
7372
7473 const branches = dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []);
74 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
75 const resolvedActiveBranch =
76 effectiveEnvMode === "worktree" && !activeWorktreePath
77 ? activeThreadBranch
78 : (currentGitBranch ?? activeThreadBranch);
7579 const branchNames = branches.map((branch) => branch.name);
7680 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
7781 const trimmedBranchQuery = branchQuery.trim();
......@@ -92,50 +96,6 @@ export default function BranchToolbar({
9296 gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
9397 );
9498
95 // ── Effects ───────────────────────────────────────────────────────────
96
97 // Keep thread branch synced to git current branch for local threads.
98 const queryBranches = branchesQuery.data?.branches;
99 useEffect(() => {
100 const syncedBranch = deriveSyncedLocalBranch({
101 activeThreadId,
102 activeWorktreePath,
103 envMode: effectiveEnvMode,
104 activeThreadBranch,
105 queryBranches,
106 });
107 if (!activeThreadId || !syncedBranch) return;
108 const api = readNativeApi();
109
110 if (api && hasServerThread) {
111 void api.orchestration.dispatchCommand({
112 type: "thread.meta.update",
113 commandId: newCommandId(),
114 threadId: activeThreadId,
115 branch: syncedBranch,
116 worktreePath: null,
117 });
118 }
119 if (hasServerThread) {
120 setThreadBranchAction(activeThreadId, syncedBranch, null);
121 return;
122 }
123 setDraftThreadContext(threadId, {
124 branch: syncedBranch,
125 worktreePath: null,
126 });
127 }, [
128 activeThreadId,
129 activeWorktreePath,
130 activeThreadBranch,
131 queryBranches,
132 effectiveEnvMode,
133 setThreadBranchAction,
134 hasServerThread,
135 setDraftThreadContext,
136 threadId,
137 ]);
138
13999 // ── Helpers ───────────────────────────────────────────────────────────
140100
141101 const setThreadError = (error: string | null) => {
......@@ -282,7 +242,7 @@ export default function BranchToolbar({
282242 }
283243 }}
284244 open={isBranchMenuOpen}
285 value={activeThreadBranch}
245 value={resolvedActiveBranch}
286246 >
287247 <ComboboxTrigger
288248 render={<Button variant="ghost" size="xs" />}
......@@ -290,10 +250,10 @@ export default function BranchToolbar({
290250 disabled={branchesQuery.isLoading}
291251 >
292252 <span className="max-w-[240px] truncate">
293 {activeThreadBranch
253 {resolvedActiveBranch
294254 ? effectiveEnvMode === "worktree" && !activeWorktreePath
295 ? `From ${activeThreadBranch}`
296 : activeThreadBranch
255 ? `From ${resolvedActiveBranch}`
256 : resolvedActiveBranch
297257 : "Select branch"}
298258 </span>
299259 <ChevronDownIcon />
......@@ -347,7 +307,7 @@ export default function BranchToolbar({
347307 key={itemValue}
348308 value={itemValue}
349309 className={
350 itemValue === activeThreadBranch ? "bg-accent text-foreground" : undefined
310 itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined
351311 }
352312 onClick={() => selectBranch(branch)}
353313 >
apps/web/src/components/ChatView.tsx+2-2
......@@ -100,7 +100,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u
100100import { Separator } from "./ui/separator";
101101import { Group, GroupSeparator } from "./ui/group";
102102import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
103import { CursorIcon, Icon } from "./Icons";
103import { CursorIcon, Icon, VisualStudioCode } from "./Icons";
104104import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
105105import { Badge } from "./ui/badge";
106106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -3681,7 +3681,7 @@ const OpenInPicker = memo(function OpenInPicker({
36813681 },
36823682 {
36833683 label: "VS Code",
3684 Icon: FileIcon,
3684 Icon: VisualStudioCode,
36853685 value: "vscode",
36863686 },
36873687 {
apps/web/src/components/Icons.tsx+107
......@@ -19,3 +19,110 @@ export const CursorIcon: Icon = (props) => (
1919 <path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
2020 </svg>
2121);
22
23export const VisualStudioCode: Icon = (props) => (
24 <svg {...props} fill="none" viewBox="0 0 100 100">
25 <mask
26 id="vscode__a"
27 width="100"
28 height="100"
29 x="0"
30 y="0"
31 maskUnits="userSpaceOnUse"
32 >
33 <path
34 fill="#fff"
35 fillRule="evenodd"
36 d="M70.912 99.317a6.223 6.223 0 0 0 4.96-.19l20.589-9.907A6.25 6.25 0 0 0 100 83.587V16.413a6.25 6.25 0 0 0-3.54-5.632L75.874.874a6.226 6.226 0 0 0-7.104 1.21L29.355 38.04 12.187 25.01a4.162 4.162 0 0 0-5.318.236l-5.506 5.009a4.168 4.168 0 0 0-.004 6.162L16.247 50 1.36 63.583a4.168 4.168 0 0 0 .004 6.162l5.506 5.01a4.162 4.162 0 0 0 5.318.236l17.168-13.032L68.77 97.917a6.217 6.217 0 0 0 2.143 1.4ZM75.015 27.3 45.11 50l29.906 22.701V27.3Z"
37 clipRule="evenodd"
38 />
39 </mask>
40 <g mask="url(#vscode__a)">
41 <path
42 fill="#0065A9"
43 d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
44 />
45 <g filter="url(#vscode__b)">
46 <path
47 fill="#007ACC"
48 d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
49 />
50 </g>
51 <g filter="url(#vscode__c)">
52 <path
53 fill="#1F9CF0"
54 d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
55 />
56 </g>
57 <path
58 fill="url(#vscode__d)"
59 fillRule="evenodd"
60 d="M70.851 99.317a6.224 6.224 0 0 0 4.96-.19L96.4 89.22a6.25 6.25 0 0 0 3.54-5.633V16.413a6.25 6.25 0 0 0-3.54-5.632L75.812.874a6.226 6.226 0 0 0-7.104 1.21L29.294 38.04 12.126 25.01a4.162 4.162 0 0 0-5.317.236l-5.507 5.009a4.168 4.168 0 0 0-.004 6.162L16.186 50 1.298 63.583a4.168 4.168 0 0 0 .004 6.162l5.507 5.009a4.162 4.162 0 0 0 5.317.236L29.294 61.96l39.414 35.958a6.218 6.218 0 0 0 2.143 1.4ZM74.954 27.3 45.048 50l29.906 22.701V27.3Z"
61 clipRule="evenodd"
62 opacity=".25"
63 style={{ mixBlendMode: "overlay" }}
64 />
65 </g>
66 <defs>
67 <filter
68 id="vscode__b"
69 width="116.727"
70 height="92.246"
71 x="-8.394"
72 y="15.829"
73 colorInterpolationFilters="sRGB"
74 filterUnits="userSpaceOnUse"
75 >
76 <feFlood floodOpacity="0" result="BackgroundImageFix" />
77 <feColorMatrix
78 in="SourceAlpha"
79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
80 />
81 <feOffset />
82 <feGaussianBlur stdDeviation="4.167" />
83 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
84 <feBlend
85 in2="BackgroundImageFix"
86 mode="overlay"
87 result="effect1_dropShadow"
88 />
89 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
90 </filter>
91 <filter
92 id="vscode__c"
93 width="47.917"
94 height="116.151"
95 x="60.417"
96 y="-8.076"
97 colorInterpolationFilters="sRGB"
98 filterUnits="userSpaceOnUse"
99 >
100 <feFlood floodOpacity="0" result="BackgroundImageFix" />
101 <feColorMatrix
102 in="SourceAlpha"
103 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
104 />
105 <feOffset />
106 <feGaussianBlur stdDeviation="4.167" />
107 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
108 <feBlend
109 in2="BackgroundImageFix"
110 mode="overlay"
111 result="effect1_dropShadow"
112 />
113 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
114 </filter>
115 <linearGradient
116 id="vscode__d"
117 x1="49.939"
118 x2="49.939"
119 y1=".258"
120 y2="99.742"
121 gradientUnits="userSpaceOnUse"
122 >
123 <stop stopColor="#fff" />
124 <stop offset="1" stopColor="#fff" stopOpacity="0" />
125 </linearGradient>
126 </defs>
127 </svg>
128);