authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 15:01:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 15:01:17-07:00
loge2316814da7463b5332054f463f71020f1c50c08
treeb3d64c3404c56470c28a0563807978376ba7d0d2
parent678f827f0b0713ab5ea9512ce12e034c2c923be8
signature Signed by PGP key B5690EEEBB952194

Fix worktree base branch updates for active draft (#1900)


7 files changed, 181 insertions(+), 9 deletions(-)

apps/web/src/components/BranchToolbar.tsx+3-1
...@@ -44,7 +44,9 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -44,7 +44,9 @@ export const BranchToolbar = memo(function BranchToolbar({
44 );44 );
45 const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);45 const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
46 const serverThread = useStore(serverThreadSelector);46 const serverThread = useStore(serverThreadSelector);
47 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));47 const draftThread = useComposerDraftStore((store) =>
48 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
49 );
48 const activeProjectRef = serverThread50 const activeProjectRef = serverThread
49 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)51 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
50 : draftThread52 : draftThread
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-1
...@@ -91,7 +91,9 @@ export function BranchToolbarBranchSelector({...@@ -91,7 +91,9 @@ export function BranchToolbarBranchSelector({
91 const serverThread = useStore(serverThreadSelector);91 const serverThread = useStore(serverThreadSelector);
92 const serverSession = serverThread?.session ?? null;92 const serverSession = serverThread?.session ?? null;
93 const setThreadBranchAction = useStore((store) => store.setThreadBranch);93 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
94 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));94 const draftThread = useComposerDraftStore((store) =>
95 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
96 );
95 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);97 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
9698
97 const activeProjectRef = serverThread99 const activeProjectRef = serverThread
apps/web/src/components/ChatView.browser.tsx+115-1
...@@ -1327,6 +1327,7 @@ async function mountChatView(options: {...@@ -1327,6 +1327,7 @@ async function mountChatView(options: {
1327 snapshot: OrchestrationReadModel;1327 snapshot: OrchestrationReadModel;
1328 configureFixture?: (fixture: TestFixture) => void;1328 configureFixture?: (fixture: TestFixture) => void;
1329 resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;1329 resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;
1330 initialPath?: string;
1330}): Promise<MountedChatView> {1331}): Promise<MountedChatView> {
1331 fixture = buildFixture(options.snapshot);1332 fixture = buildFixture(options.snapshot);
1332 options.configureFixture?.(fixture);1333 options.configureFixture?.(fixture);
...@@ -1346,7 +1347,7 @@ async function mountChatView(options: {...@@ -1346,7 +1347,7 @@ async function mountChatView(options: {
13461347
1347 const router = getRouter(1348 const router = getRouter(
1348 createMemoryHistory({1349 createMemoryHistory({
1349 initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],1350 initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],
1350 }),1351 }),
1351 );1352 );
13521353
...@@ -2512,6 +2513,119 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2512,6 +2513,119 @@ describe("ChatView timeline estimator parity (full app)", () => {
2512 }2513 }
2513 });2514 });
25142515
2516 it("uses the active draft route session when changing the base branch", async () => {
2517 const staleDraftId = draftIdFromPath("/draft/draft-stale-branch-session");
2518 const activeDraftId = draftIdFromPath("/draft/draft-active-branch-session");
2519
2520 useComposerDraftStore.setState({
2521 draftThreadsByThreadKey: {
2522 [staleDraftId]: {
2523 threadId: THREAD_ID,
2524 environmentId: LOCAL_ENVIRONMENT_ID,
2525 projectId: PROJECT_ID,
2526 logicalProjectKey: `${PROJECT_DRAFT_KEY}:stale`,
2527 createdAt: NOW_ISO,
2528 runtimeMode: "full-access",
2529 interactionMode: "default",
2530 branch: "main",
2531 worktreePath: null,
2532 envMode: "worktree",
2533 },
2534 [activeDraftId]: {
2535 threadId: THREAD_ID,
2536 environmentId: LOCAL_ENVIRONMENT_ID,
2537 projectId: PROJECT_ID,
2538 logicalProjectKey: PROJECT_DRAFT_KEY,
2539 createdAt: NOW_ISO,
2540 runtimeMode: "full-access",
2541 interactionMode: "default",
2542 branch: "main",
2543 worktreePath: null,
2544 envMode: "worktree",
2545 },
2546 },
2547 logicalProjectDraftThreadKeyByLogicalProjectKey: {
2548 [`${PROJECT_DRAFT_KEY}:stale`]: staleDraftId,
2549 [PROJECT_DRAFT_KEY]: activeDraftId,
2550 },
2551 });
2552
2553 const mounted = await mountChatView({
2554 viewport: DEFAULT_VIEWPORT,
2555 snapshot: createDraftOnlySnapshot(),
2556 initialPath: `/draft/${activeDraftId}`,
2557 resolveRpc: (body) => {
2558 if (body._tag === WS_METHODS.gitListBranches) {
2559 return {
2560 isRepo: true,
2561 hasOriginRemote: true,
2562 nextCursor: null,
2563 totalCount: 2,
2564 branches: [
2565 {
2566 name: "main",
2567 current: true,
2568 isDefault: true,
2569 worktreePath: null,
2570 },
2571 {
2572 name: "release/next",
2573 current: false,
2574 isDefault: false,
2575 worktreePath: null,
2576 },
2577 ],
2578 };
2579 }
2580 return undefined;
2581 },
2582 });
2583
2584 try {
2585 const branchButton = await waitForElement(
2586 () =>
2587 Array.from(document.querySelectorAll("button")).find(
2588 (button) => button.textContent?.trim() === "From main",
2589 ) as HTMLButtonElement | null,
2590 'Unable to find branch selector button with "From main".',
2591 );
2592 branchButton.click();
2593
2594 const branchOption = await waitForElement(
2595 () =>
2596 Array.from(document.querySelectorAll("span")).find(
2597 (element) => element.textContent?.trim() === "release/next",
2598 ) as HTMLSpanElement | null,
2599 'Unable to find the "release/next" branch option.',
2600 );
2601 branchOption.click();
2602
2603 await vi.waitFor(
2604 () => {
2605 expect(useComposerDraftStore.getState().getDraftSession(activeDraftId)?.branch).toBe(
2606 "release/next",
2607 );
2608 expect(useComposerDraftStore.getState().getDraftSession(staleDraftId)?.branch).toBe(
2609 "main",
2610 );
2611 },
2612 { timeout: 8_000, interval: 16 },
2613 );
2614
2615 await vi.waitFor(
2616 () => {
2617 const updatedButton = Array.from(document.querySelectorAll("button")).find((button) =>
2618 button.textContent?.trim().includes("From release/next"),
2619 );
2620 expect(updatedButton).toBeTruthy();
2621 },
2622 { timeout: 8_000, interval: 16 },
2623 );
2624 } finally {
2625 await mounted.cleanup();
2626 }
2627 });
2628
2515 it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {2629 it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {
2516 const mounted = await mountChatView({2630 const mounted = await mountChatView({
2517 viewport: DEFAULT_VIEWPORT,2631 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+1
...@@ -3289,6 +3289,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3289,6 +3289,7 @@ export default function ChatView(props: ChatViewProps) {
3289 <ChatHeader3289 <ChatHeader
3290 activeThreadEnvironmentId={activeThread.environmentId}3290 activeThreadEnvironmentId={activeThread.environmentId}
3291 activeThreadId={activeThread.id}3291 activeThreadId={activeThread.id}
3292 {...(routeKind === "draft" && draftId ? { draftId } : {})}
3292 activeThreadTitle={activeThread.title}3293 activeThreadTitle={activeThread.title}
3293 activeProjectName={activeProject?.name}3294 activeProjectName={activeProject?.name}
3294 isGitRepo={isGitRepo}3295 isGitRepo={isGitRepo}
apps/web/src/components/GitActionsControl.browser.tsx+33
...@@ -426,4 +426,37 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -426,4 +426,37 @@ describe("GitActionsControl thread-scoped progress toast", () => {
426 host.remove();426 host.remove();
427 }427 }
428 });428 });
429
430 it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => {
431 hasServerThreadRef.current = false;
432 activeDraftThreadRef.current = {
433 threadId: SHARED_THREAD_ID,
434 environmentId: ENVIRONMENT_A,
435 branch: "feature/base-branch",
436 worktreePath: null,
437 envMode: "worktree",
438 };
439
440 const host = document.createElement("div");
441 document.body.append(host);
442 const screen = await render(
443 <GitActionsControl
444 gitCwd={GIT_CWD}
445 activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}
446 />,
447 {
448 container: host,
449 },
450 );
451
452 try {
453 await Promise.resolve();
454
455 expect(setDraftThreadContextSpy).not.toHaveBeenCalled();
456 expect(setThreadBranchSpy).not.toHaveBeenCalled();
457 } finally {
458 await screen.unmount();
459 host.remove();
460 }
461 });
429});462});
apps/web/src/components/GitActionsControl.tsx+22-6
...@@ -49,7 +49,7 @@ import {...@@ -49,7 +49,7 @@ import {
49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
50import { newCommandId, randomUUID } from "~/lib/utils";50import { newCommandId, randomUUID } from "~/lib/utils";
51import { resolvePathLinkTarget } from "~/terminal-links";51import { resolvePathLinkTarget } from "~/terminal-links";
52import { useComposerDraftStore } from "~/composerDraftStore";52import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
53import { readEnvironmentApi } from "~/environmentApi";53import { readEnvironmentApi } from "~/environmentApi";
54import { readLocalApi } from "~/localApi";54import { readLocalApi } from "~/localApi";
55import { useStore } from "~/store";55import { useStore } from "~/store";
...@@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors";...@@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors";
58interface GitActionsControlProps {58interface GitActionsControlProps {
59 gitCwd: string | null;59 gitCwd: string | null;
60 activeThreadRef: ScopedThreadRef | null;60 activeThreadRef: ScopedThreadRef | null;
61 draftId?: DraftId;
61}62}
6263
63interface PendingDefaultBranchAction {64interface PendingDefaultBranchAction {
...@@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
209 return <InfoIcon className={iconClassName} />;210 return <InfoIcon className={iconClassName} />;
210}211}
211212
212export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {213export default function GitActionsControl({
214 gitCwd,
215 activeThreadRef,
216 draftId,
217}: GitActionsControlProps) {
213 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;218 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
214 const threadToastData = useMemo(219 const threadToastData = useMemo(
215 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),220 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
...@@ -221,7 +226,11 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction...@@ -221,7 +226,11 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
221 );226 );
222 const activeServerThread = useStore(activeServerThreadSelector);227 const activeServerThread = useStore(activeServerThreadSelector);
223 const activeDraftThread = useComposerDraftStore((store) =>228 const activeDraftThread = useComposerDraftStore((store) =>
224 activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null,229 draftId
230 ? store.getDraftSession(draftId)
231 : activeThreadRef
232 ? store.getDraftThreadByRef(activeThreadRef)
233 : null,
225 );234 );
226 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);235 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
227 const setThreadBranch = useStore((store) => store.setThreadBranch);236 const setThreadBranch = useStore((store) => store.setThreadBranch);
...@@ -282,7 +291,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction...@@ -282,7 +291,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
282 return;291 return;
283 }292 }
284293
285 setDraftThreadContext(activeThreadRef, {294 setDraftThreadContext(draftId ?? activeThreadRef, {
286 branch,295 branch,
287 worktreePath: activeDraftThread.worktreePath,296 worktreePath: activeDraftThread.worktreePath,
288 });297 });
...@@ -291,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction...@@ -291,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
291 activeDraftThread,300 activeDraftThread,
292 activeServerThread,301 activeServerThread,
293 activeThreadRef,302 activeThreadRef,
303 draftId,
294 setDraftThreadContext,304 setDraftThreadContext,
295 setThreadBranch,305 setThreadBranch,
296 ],306 ],
...@@ -344,14 +354,18 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction...@@ -344,14 +354,18 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
344 const isPullRunning =354 const isPullRunning =
345 useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;355 useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
346 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;356 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
357 const isSelectingWorktreeBase =
358 !activeServerThread &&
359 activeDraftThread?.envMode === "worktree" &&
360 activeDraftThread.worktreePath === null;
347361
348 useEffect(() => {362 useEffect(() => {
349 if (isGitActionRunning) {363 if (isGitActionRunning || isSelectingWorktreeBase) {
350 return;364 return;
351 }365 }
352366
353 const branchUpdate = resolveLiveThreadBranchUpdate({367 const branchUpdate = resolveLiveThreadBranchUpdate({
354 threadBranch: activeServerThread?.branch ?? null,368 threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null,
355 gitStatus: gitStatusForActions,369 gitStatus: gitStatusForActions,
356 });370 });
357 if (!branchUpdate) {371 if (!branchUpdate) {
...@@ -361,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction...@@ -361,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
361 persistThreadBranchSync(branchUpdate.branch);375 persistThreadBranchSync(branchUpdate.branch);
362 }, [376 }, [
363 activeServerThread?.branch,377 activeServerThread?.branch,
378 activeDraftThread?.branch,
364 gitStatusForActions,379 gitStatusForActions,
365 isGitActionRunning,380 isGitActionRunning,
381 isSelectingWorktreeBase,
366 persistThreadBranchSync,382 persistThreadBranchSync,
367 ]);383 ]);
368384
apps/web/src/components/chat/ChatHeader.tsx+4
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8import { scopeThreadRef } from "@t3tools/client-runtime";8import { scopeThreadRef } from "@t3tools/client-runtime";
9import { memo } from "react";9import { memo } from "react";
10import GitActionsControl from "../GitActionsControl";10import GitActionsControl from "../GitActionsControl";
11import { type DraftId } from "~/composerDraftStore";
11import { DiffIcon, TerminalSquareIcon } from "lucide-react";12import { DiffIcon, TerminalSquareIcon } from "lucide-react";
12import { Badge } from "../ui/badge";13import { Badge } from "../ui/badge";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -19,6 +20,7 @@ import { OpenInPicker } from "./OpenInPicker";...@@ -19,6 +20,7 @@ import { OpenInPicker } from "./OpenInPicker";
19interface ChatHeaderProps {20interface ChatHeaderProps {
20 activeThreadEnvironmentId: EnvironmentId;21 activeThreadEnvironmentId: EnvironmentId;
21 activeThreadId: ThreadId;22 activeThreadId: ThreadId;
23 draftId?: DraftId;
22 activeThreadTitle: string;24 activeThreadTitle: string;
23 activeProjectName: string | undefined;25 activeProjectName: string | undefined;
24 isGitRepo: boolean;26 isGitRepo: boolean;
...@@ -44,6 +46,7 @@ interface ChatHeaderProps {...@@ -44,6 +46,7 @@ interface ChatHeaderProps {
44export const ChatHeader = memo(function ChatHeader({46export const ChatHeader = memo(function ChatHeader({
45 activeThreadEnvironmentId,47 activeThreadEnvironmentId,
46 activeThreadId,48 activeThreadId,
49 draftId,
47 activeThreadTitle,50 activeThreadTitle,
48 activeProjectName,51 activeProjectName,
49 isGitRepo,52 isGitRepo,
...@@ -109,6 +112,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -109,6 +112,7 @@ export const ChatHeader = memo(function ChatHeader({
109 <GitActionsControl112 <GitActionsControl
110 gitCwd={gitCwd}113 gitCwd={gitCwd}
111 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}114 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
115 {...(draftId ? { draftId } : {})}
112 />116 />
113 )}117 )}
114 <Tooltip>118 <Tooltip>