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({
4444 );
4545 const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
4646 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 );
4850 const activeProjectRef = serverThread
4951 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
5052 : draftThread
apps/web/src/components/BranchToolbarBranchSelector.tsx+3-1
......@@ -91,7 +91,9 @@ export function BranchToolbarBranchSelector({
9191 const serverThread = useStore(serverThreadSelector);
9292 const serverSession = serverThread?.session ?? null;
9393 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 );
9597 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
9698
9799 const activeProjectRef = serverThread
apps/web/src/components/ChatView.browser.tsx+115-1
......@@ -1327,6 +1327,7 @@ async function mountChatView(options: {
13271327 snapshot: OrchestrationReadModel;
13281328 configureFixture?: (fixture: TestFixture) => void;
13291329 resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined;
1330 initialPath?: string;
13301331}): Promise<MountedChatView> {
13311332 fixture = buildFixture(options.snapshot);
13321333 options.configureFixture?.(fixture);
......@@ -1346,7 +1347,7 @@ async function mountChatView(options: {
13461347
13471348 const router = getRouter(
13481349 createMemoryHistory({
1349 initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],
1350 initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`],
13501351 }),
13511352 );
13521353
......@@ -2512,6 +2513,119 @@ describe("ChatView timeline estimator parity (full app)", () => {
25122513 }
25132514 });
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
25152629 it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {
25162630 const mounted = await mountChatView({
25172631 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+1
......@@ -3289,6 +3289,7 @@ export default function ChatView(props: ChatViewProps) {
32893289 <ChatHeader
32903290 activeThreadEnvironmentId={activeThread.environmentId}
32913291 activeThreadId={activeThread.id}
3292 {...(routeKind === "draft" && draftId ? { draftId } : {})}
32923293 activeThreadTitle={activeThread.title}
32933294 activeProjectName={activeProject?.name}
32943295 isGitRepo={isGitRepo}
apps/web/src/components/GitActionsControl.browser.tsx+33
......@@ -426,4 +426,37 @@ describe("GitActionsControl thread-scoped progress toast", () => {
426426 host.remove();
427427 }
428428 });
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 });
429462});
apps/web/src/components/GitActionsControl.tsx+22-6
......@@ -49,7 +49,7 @@ import {
4949import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050import { newCommandId, randomUUID } from "~/lib/utils";
5151import { resolvePathLinkTarget } from "~/terminal-links";
52import { useComposerDraftStore } from "~/composerDraftStore";
52import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5353import { readEnvironmentApi } from "~/environmentApi";
5454import { readLocalApi } from "~/localApi";
5555import { useStore } from "~/store";
......@@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors";
5858interface GitActionsControlProps {
5959 gitCwd: string | null;
6060 activeThreadRef: ScopedThreadRef | null;
61 draftId?: DraftId;
6162}
6263
6364interface PendingDefaultBranchAction {
......@@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
209210 return <InfoIcon className={iconClassName} />;
210211}
211212
212export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {
213export default function GitActionsControl({
214 gitCwd,
215 activeThreadRef,
216 draftId,
217}: GitActionsControlProps) {
213218 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
214219 const threadToastData = useMemo(
215220 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
......@@ -221,7 +226,11 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
221226 );
222227 const activeServerThread = useStore(activeServerThreadSelector);
223228 const activeDraftThread = useComposerDraftStore((store) =>
224 activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null,
229 draftId
230 ? store.getDraftSession(draftId)
231 : activeThreadRef
232 ? store.getDraftThreadByRef(activeThreadRef)
233 : null,
225234 );
226235 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
227236 const setThreadBranch = useStore((store) => store.setThreadBranch);
......@@ -282,7 +291,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
282291 return;
283292 }
284293
285 setDraftThreadContext(activeThreadRef, {
294 setDraftThreadContext(draftId ?? activeThreadRef, {
286295 branch,
287296 worktreePath: activeDraftThread.worktreePath,
288297 });
......@@ -291,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
291300 activeDraftThread,
292301 activeServerThread,
293302 activeThreadRef,
303 draftId,
294304 setDraftThreadContext,
295305 setThreadBranch,
296306 ],
......@@ -344,14 +354,18 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
344354 const isPullRunning =
345355 useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
346356 const isGitActionRunning = isRunStackedActionRunning || isPullRunning;
357 const isSelectingWorktreeBase =
358 !activeServerThread &&
359 activeDraftThread?.envMode === "worktree" &&
360 activeDraftThread.worktreePath === null;
347361
348362 useEffect(() => {
349 if (isGitActionRunning) {
363 if (isGitActionRunning || isSelectingWorktreeBase) {
350364 return;
351365 }
352366
353367 const branchUpdate = resolveLiveThreadBranchUpdate({
354 threadBranch: activeServerThread?.branch ?? null,
368 threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null,
355369 gitStatus: gitStatusForActions,
356370 });
357371 if (!branchUpdate) {
......@@ -361,8 +375,10 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
361375 persistThreadBranchSync(branchUpdate.branch);
362376 }, [
363377 activeServerThread?.branch,
378 activeDraftThread?.branch,
364379 gitStatusForActions,
365380 isGitActionRunning,
381 isSelectingWorktreeBase,
366382 persistThreadBranchSync,
367383 ]);
368384
apps/web/src/components/chat/ChatHeader.tsx+4
......@@ -8,6 +8,7 @@ import {
88import { scopeThreadRef } from "@t3tools/client-runtime";
99import { memo } from "react";
1010import GitActionsControl from "../GitActionsControl";
11import { type DraftId } from "~/composerDraftStore";
1112import { DiffIcon, TerminalSquareIcon } from "lucide-react";
1213import { Badge } from "../ui/badge";
1314import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -19,6 +20,7 @@ import { OpenInPicker } from "./OpenInPicker";
1920interface ChatHeaderProps {
2021 activeThreadEnvironmentId: EnvironmentId;
2122 activeThreadId: ThreadId;
23 draftId?: DraftId;
2224 activeThreadTitle: string;
2325 activeProjectName: string | undefined;
2426 isGitRepo: boolean;
......@@ -44,6 +46,7 @@ interface ChatHeaderProps {
4446export const ChatHeader = memo(function ChatHeader({
4547 activeThreadEnvironmentId,
4648 activeThreadId,
49 draftId,
4750 activeThreadTitle,
4851 activeProjectName,
4952 isGitRepo,
......@@ -109,6 +112,7 @@ export const ChatHeader = memo(function ChatHeader({
109112 <GitActionsControl
110113 gitCwd={gitCwd}
111114 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
115 {...(draftId ? { draftId } : {})}
112116 />
113117 )}
114118 <Tooltip>