Skip to content

Commit e231681

Browse files
Fix worktree base branch updates for active draft (#1900)
1 parent 678f827 commit e231681

7 files changed

Lines changed: 181 additions & 9 deletions

File tree

apps/web/src/components/BranchToolbar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5050
import { newCommandId, randomUUID } from "~/lib/utils";
5151
import { resolvePathLinkTarget } from "~/terminal-links";
52-
import { useComposerDraftStore } from "~/composerDraftStore";
52+
import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5353
import { readEnvironmentApi } from "~/environmentApi";
5454
import { readLocalApi } from "~/localApi";
5555
import { useStore } from "~/store";
@@ -58,6 +58,7 @@ import { createThreadSelectorByRef } from "~/storeSelectors";
5858
interface GitActionsControlProps {
5959
gitCwd: string | null;
6060
activeThreadRef: ScopedThreadRef | null;
61+
draftId?: DraftId;
6162
}
6263

6364
interface PendingDefaultBranchAction {
@@ -209,7 +210,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
209210
return <InfoIcon className={iconClassName} />;
210211
}
211212

212-
export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {
213+
export 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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { scopeThreadRef } from "@t3tools/client-runtime";
99
import { memo } from "react";
1010
import GitActionsControl from "../GitActionsControl";
11+
import { type DraftId } from "~/composerDraftStore";
1112
import { DiffIcon, TerminalSquareIcon } from "lucide-react";
1213
import { Badge } from "../ui/badge";
1314
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -19,6 +20,7 @@ import { OpenInPicker } from "./OpenInPicker";
1920
interface 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 {
4446
export 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>

0 commit comments

Comments
 (0)