# Files changed · PR \#15255 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/15255/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): branch picker no longer interrupts a running turn](https://git.cafe/pingdotgg/t3code/pull/15255.md)

Version: 26

Head commit: d93656badef5c467f7679d21aafe61709047a11c

Base commit: f0fb0e8525fca72f0259c7b44a2b15aafde52a31

Comparison base: f0fb0e8525fca72f0259c7b44a2b15aafde52a31

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/BranchPicker.tsx

Status: modified; +4; −0

```diff
@@ -41,6 +41,7 @@
   isFetchingNextPage,
   onLoadNext,
   statusText,
+  notice,
   originControl,
   popupProps,
   renderItem,
@@ -60,6 +61,7 @@
   isFetchingNextPage: boolean;
   onLoadNext: () => void;
   statusText: string | null;
+  notice?: string | null | undefined;
   originControl?: { checked: boolean; onCheckedChange: (checked: boolean) => void } | undefined;
   popupProps: Omit<ComponentProps<typeof ComboboxPopup>, "children">;
   renderItem: (value: string, index: number) => ReactNode;
@@ -198,6 +200,7 @@
                 keyExtractor={(item) => item}
                 {...(getItemType ? { getItemType } : {})}
                 renderItem={({ item, index }) => renderItem(item, index)}
+                extraData={renderItem}
                 estimatedItemSize={28}
                 drawDistance={336}
                 onLayout={() => {
@@ -247,6 +250,7 @@
               </TooltipPopup>
             </Tooltip>
           ) : null}
+          {notice ? <ComboboxStatus>{notice}</ComboboxStatus> : null}
           {statusText ? <ComboboxStatus>{statusText}</ComboboxStatus> : null}
         </div>
       </ComboboxPopup>
```

## apps/web/src/components/BranchToolbar.logic.ts

Status: modified; +13; −0

```diff
@@ -5,9 +5,11 @@
   ProjectId,
   WorktreeSubmodules,
 } from "@t3tools/contracts";
+import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
 import * as Schema from "effect/Schema";
 import { sanitizeNewRefName } from "@t3tools/shared/git";
 import { toSortableTimestamp } from "../lib/threadSort";
+import { deriveCanInterruptRunningThread } from "../session-logic";
 export {
   dedupeRemoteBranchesWithLocalMatches,
   deriveLocalBranchNameFromRemoteRef,
@@ -276,6 +278,17 @@
   };
 }
 
+export function isWorktreeChangeBlocked(input: {
+  runtime: ThreadRuntimeSummary | null;
+  currentWorktreePath: string | null;
+  nextWorktreePath: string | null;
+}): boolean {
+  return (
+    input.nextWorktreePath !== input.currentWorktreePath &&
+    deriveCanInterruptRunningThread(true, input.runtime)
+  );
+}
+
 export function shouldIncludeBranchPickerItem(input: {
   itemValue: string;
   normalizedQuery: string;
```

## apps/web/src/components/BranchToolbar.panel.browser.test.tsx

Status: modified; +212; −6

```diff
@@ -1,4 +1,12 @@
+import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
+import {
+  EnvironmentId,
+  ProjectId,
+  ProviderInstanceId,
+  RunId,
-import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
+  ThreadId,
+  type VcsRef,
+} from "@t3tools/contracts";
 import { act, type ComponentProps } from "react";
 import { createRoot, type Root } from "react-dom/client";
 import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
@@ -8,6 +16,14 @@
 
 const state = vi.hoisted(() => ({
   worktreePath: "/tmp/worktree" as string | null,
+  runtime: null as ThreadRuntimeSummary | null,
+  latest: null as { worktreePath: string | null; runtime: ThreadRuntimeSummary | null } | null,
+  refs: [] as VcsRef[],
+  updateMetadata: vi.fn(),
+  stopSession: vi.fn(),
+  switchRef: vi.fn(),
+  createRef: vi.fn(),
+  refresh: vi.fn(),
   showContextMenu: vi.fn(),
   writeTextToClipboard: vi.fn(),
 }));
@@ -20,21 +36,78 @@
   ...(await original()),
   writeTextToClipboard: state.writeTextToClipboard,
 }));
-vi.mock(import("./BranchToolbarBranchSelector"), async (original) => ({
-  ...(await original()),
-  BranchToolbarBranchSelector: (() => null) as never,
-}));
 vi.mock(import("../state/entities"), async (original) => ({
   ...(await original()),
   useQuietThreadShell: (() => ({
+    id: "thread",
     environmentId: "local",
     projectId: "project",
+    branch: "topic",
     worktreePath: state.worktreePath,
+    runtime: state.runtime,
+    pullRequests: [],
   })) as never,
+  readThreadShell: (() =>
+    state.latest ?? {
+      worktreePath: state.worktreePath,
+      runtime: state.runtime,
+    }) as never,
+  useProject: (() => ({ id: "project", workspaceRoot: "/tmp/project" })) as never,
-  useProject: (() => ({ workspaceRoot: "/tmp/project" })) as never,
   useThreadShellsForProjectRefs: () => [],
 }));
 
+vi.mock(import("../state/session"), async (original) => ({
+  ...(await original()),
+  useEnvironmentScope: () => true,
+  readEnvironmentScope: () => true,
+}));
+vi.mock(import("../state/use-atom-command"), async (original) => {
+  const { threadEnvironment } = await import("../state/threads");
+  const { vcsEnvironment } = await import("../state/vcs");
+  return {
+    ...(await original()),
+    useAtomCommand: ((command: unknown) =>
+      command === threadEnvironment.stopSession
+        ? state.stopSession
+        : command === threadEnvironment.updateMetadata
+          ? state.updateMetadata
+          : command === vcsEnvironment.switchRef
+            ? state.switchRef
+            : state.createRef) as never,
+  };
+});
+vi.mock(import("../state/query"), async (original) => ({
+  ...(await original()),
+  useEnvironmentQuery: (() => ({
+    data: { refName: "topic", refs: state.refs },
+    refresh: state.refresh,
+  })) as never,
+}));
+vi.mock(import("../state/queries"), async (original) => ({
+  ...(await original()),
+  usePaginatedBranches: (() => ({
+    refs: state.refs,
+    data: { nextCursor: null, totalCount: state.refs.length },
+    refresh: state.refresh,
+    isPending: false,
+  })) as never,
+}));
+vi.mock(import("../hooks/useSupportsMultiplePullRequests"), async (original) => ({
+  ...(await original()),
+  useSupportsMultiplePullRequests: () => false,
+}));
+vi.mock(import("../lib/openPullRequestLink"), async (original) => ({
+  ...(await original()),
+  useOpenPrLink: () => vi.fn(),
+}));
+vi.mock(import("./ThreadStatusIndicators"), async (original) => ({
+  ...(await original()),
+  ThreadPullRequestBadgeControl: (() => null) as never,
+  prStatusIndicator: () => null,
+  resolveThreadPullRequestBadge: () => null,
+  useLinkedThreadPullRequest: () => null,
+}));
+
 import { BranchToolbar } from "./BranchToolbar";
 
 let root: Root;
@@ -43,6 +116,32 @@
 beforeEach(() => {
   vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
   state.worktreePath = "/tmp/worktree";
+  state.runtime = null;
+  state.latest = null;
+  state.refs = [
+    {
+      name: "main",
+      isDefault: true,
+      isRemote: false,
+      current: false,
+      worktreePath: "/tmp/project",
+    },
+    {
+      name: "topic",
+      isDefault: false,
+      isRemote: false,
+      current: true,
+      worktreePath: "/tmp/worktree",
+    },
+    { name: "other", isDefault: false, isRemote: false, current: false, worktreePath: null },
+  ];
+  state.updateMetadata.mockReset().mockResolvedValue({ _tag: "Success" });
+  state.stopSession.mockReset().mockResolvedValue({ _tag: "Success" });
+  state.switchRef.mockReset().mockResolvedValue({ _tag: "Success", value: { refName: "other" } });
+  state.createRef
+    .mockReset()
+    .mockResolvedValue({ _tag: "Success", value: { refName: "new-topic" } });
+  state.refresh.mockReset();
   state.showContextMenu.mockReset().mockResolvedValue("copy-path");
   state.writeTextToClipboard.mockReset().mockResolvedValue(true);
   container = document.createElement("div");
@@ -146,3 +245,110 @@
     }
   },
 );
+
+function running(status: ThreadRuntimeSummary["status"] = "running"): ThreadRuntimeSummary {
+  return {
+    status,
+    activeRunId: RunId.make("new-run"),
+    providerInstanceId: ProviderInstanceId.make("codex"),
+    providerName: null,
+    lastError: null,
+    updatedAt: "2026-10-16T00:00:00Z",
+  };
+}
+
+const branchProps = { panelSection: "branch", envMode: "worktree", envLocked: true } as const;
+
+async function openBranches() {
+  await userEvent.click(container.querySelector<HTMLElement>('[data-slot="combobox-trigger"]')!);
+  await expect.element(page.getByRole("option", { name: "main default" })).toBeVisible();
+}
+
+it.each([
+  ["preparing", "panel"],
+  ["starting", "composer"],
+  ["running", "panel"],
+  ["waiting", "composer"],
+] as const)("keeps a %s turn in its worktree through the %s picker", async (status, layout) => {
+  state.runtime = running(status);
+  const props = { ...branchProps, layout };
+  await render(props);
+  await openBranches();
+  await expect
+    .element(page.getByRole("option", { name: "main default" }))
+    .toHaveAttribute("aria-disabled", "true");
+  await expect.element(page.getByText("Stop the current turn to switch worktrees.")).toBeVisible();
+  await userEvent.click(page.getByRole("option", { name: "topic current" }));
+  expect(state.updateMetadata).toHaveBeenCalledWith({
+    environmentId: "local",
+    input: { threadId: "thread", branch: "topic", worktreePath: "/tmp/worktree" },
+  });
+  expect(state.stopSession).not.toHaveBeenCalled();
+
+  await openBranches();
+  state.runtime = null;
+  await render(props);
+  await expect
+    .element(page.getByRole("option", { name: "main default" }))
+    .not.toHaveAttribute("aria-disabled", "true");
+  await userEvent.click(page.getByRole("option", { name: "main default" }));
+  expect(state.updateMetadata).toHaveBeenLastCalledWith({
+    environmentId: "local",
+    input: { threadId: "thread", branch: "main", worktreePath: null },
+  });
+});
+
+it.each(["existing worktree", "worktree base"] as const)(
+  "rechecks the store before applying a stale %s selection",
+  async (selection) => {
+    state.worktreePath = null;
+    await render({ ...branchProps, envLocked: selection === "existing worktree" });
+    await openBranches();
+    const option = page.getByRole("option", {
+      name: selection === "existing worktree" ? "topic current" : "main default",
+    });
+    await expect.element(option).not.toHaveAttribute("aria-disabled", "true");
+    // Simulate another client's update before React has rendered it.
+    state.latest = { worktreePath: "/tmp/new-worktree", runtime: running() };
+    await userEvent.click(option);
+    expect(state.updateMetadata).not.toHaveBeenCalled();
+    expect(state.stopSession).not.toHaveBeenCalled();
+    expect(state.switchRef).not.toHaveBeenCalled();
+  },
+);
+
+it("does not automatically assign a base after another client starts the thread", async () => {
+  state.worktreePath = null;
+  state.latest = { worktreePath: "/tmp/new-worktree", runtime: running("preparing") };
+  await render({ ...branchProps, envLocked: false, activeThreadBranchOverride: null });
+  expect(state.updateMetadata).not.toHaveBeenCalled();
+  expect(state.stopSession).not.toHaveBeenCalled();
+});
+
+it.each(["checkout", "ref creation"] as const)(
+  "does not stop or retarget a run that starts while %s awaits",
+  async (action) => {
+    const result = { _tag: "Success", value: { refName: "new-topic" } } as const;
+    let resolve!: (value: typeof result) => void;
+    const pending = new Promise<typeof result>((done) => {
+      resolve = done;
+    });
+    const mutation = action === "checkout" ? state.switchRef : state.createRef;
+    mutation.mockReturnValueOnce(pending);
+    await render(branchProps);
+    await openBranches();
+    if (action === "checkout") {
+      await userEvent.click(page.getByRole("option", { name: "other", exact: true }));
+    } else {
+      await userEvent.fill(page.getByPlaceholder("Search refs..."), "new-topic");
+      await userEvent.click(page.getByRole("option", { name: 'Create new ref "new-topic"' }));
+    }
+    expect(mutation).toHaveBeenCalledOnce();
+    expect(state.updateMetadata).not.toHaveBeenCalled();
+    state.latest = { worktreePath: "/tmp/new-worktree", runtime: running() };
+    await act(async () => resolve(result));
+    expect(state.refresh).toHaveBeenCalled();
+    expect(state.stopSession).not.toHaveBeenCalled();
+    expect(state.updateMetadata).not.toHaveBeenCalled();
+  },
+);
```

## apps/web/src/components/BranchToolbarBranchSelector.tsx

Status: modified; +70; −12

```diff
@@ -33,7 +33,7 @@
 import { readLocalApi } from "../localApi";
 import { useOpenPrLink } from "../lib/openPullRequestLink";
 import { usePaginatedBranches } from "../state/queries";
-import { useProject, useQuietThreadShell } from "../state/entities";
+import { readThreadShell, useProject, useQuietThreadShell } from "../state/entities";
 import { useEnvironmentQuery } from "../state/query";
 import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
 import { threadEnvironment } from "../state/threads";
@@ -47,6 +47,7 @@
 import { useComposerMenuProps } from "./chat/composerEventScope";
 import {
   deriveLocalBranchNameFromRemoteRef,
+  isWorktreeChangeBlocked,
   resolveBranchTriggerLabel,
   resolveBranchToolbarPrBranch,
   resolveBranchSelectionTarget,
@@ -91,6 +92,8 @@
   onComposerFocusRequest?: () => void;
 }
 
+const WORKTREE_CHANGE_BLOCKED_MESSAGE = "Stop the current turn to switch worktrees.";
+
 function toBranchActionErrorMessage(error: unknown): string {
   return error instanceof Error ? error.message : "An error occurred.";
 }
@@ -151,7 +154,9 @@
       : (serverThread?.branch ?? draftThread?.branch ?? null);
   const activeWorktreePath = forceNewWorktree
     ? null
+    : serverThread
+      ? serverThread.worktreePath
+      : (draftThread?.worktreePath ?? null);
-    : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null);
   const activeProjectCwd = activeProject?.workspaceRoot ?? null;
   const branchCwd = activeWorktreePath ?? activeProjectCwd;
   const hasServerThread = serverThread !== null;
@@ -165,6 +170,10 @@
       draftThreadEnvMode: draftThread?.envMode,
     });
 
+  const readLatestThreadState = () =>
+    readThreadShell(threadRef) ??
+    serverThread ?? { runtime: null, worktreePath: activeWorktreePath };
+
   const setThreadBranch = (
     branch: string | null,
     worktreePath: string | null,
@@ -175,8 +184,22 @@
       !activeProject ||
       (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
     )
+      return false;
+    const latest = readLatestThreadState();
+    if (automatic && (latest.runtime || latest.worktreePath)) return false;
+    if (
+      isWorktreeChangeBlocked({
+        runtime: latest.runtime,
+        currentWorktreePath: latest.worktreePath,
+        nextWorktreePath: worktreePath,
+      })
+    ) {
+      toastManager.add(
-      return;
-    if (serverSession && worktreePath !== activeWorktreePath) {
+        stackedThreadToast({ type: "warning", title: WORKTREE_CHANGE_BLOCKED_MESSAGE }),
+      );
+      return false;
+    }
+    if (latest.runtime && worktreePath !== latest.worktreePath) {
       void stopThreadSession({
         environmentId,
         input: { threadId: activeThreadId },
@@ -195,7 +218,7 @@
     }
     if (hasServerThread) {
       onActiveThreadBranchOverrideChange?.(branch);
-      return;
+      return true;
     }
     const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
       nextWorktreePath: worktreePath,
@@ -209,6 +232,7 @@
       environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual",
       projectRef: scopeProjectRef(environmentId, activeProject.id),
     });
+    return true;
   };
 
   const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
@@ -240,6 +264,19 @@
   const prReference = parsePullRequestReference(trimmedBranchQuery);
   const isSelectingWorktreeBase =
     effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
+  const isWorktreeChangeBlockedForRef = (refName: VcsRef) =>
+    activeProjectCwd !== null &&
+    isWorktreeChangeBlocked({
+      runtime: serverSession,
+      currentWorktreePath: serverThread ? serverThread.worktreePath : activeWorktreePath,
+      nextWorktreePath: isSelectingWorktreeBase
+        ? null
+        : resolveBranchSelectionTarget({
+            activeProjectCwd,
+            activeWorktreePath,
+            refName,
+          }).nextWorktreePath,
+    });
   const checkoutPullRequestItemValue =
     canChangeThreadBranch && prReference && onCheckoutPullRequestRequest
       ? `__checkout_pull_request__:${prReference}`
@@ -382,19 +419,29 @@
   const selectBranch = (refName: VcsRef) => {
     if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
 
+    const latest = readLatestThreadState();
     if (isSelectingWorktreeBase) {
+      if (latest.worktreePath) return;
       setThreadBranch(refName.name, null);
       setIsBranchMenuOpen(false);
       onComposerFocusRequest?.();
       return;
     }
-
     const selectionTarget = resolveBranchSelectionTarget({
       activeProjectCwd,
-      activeWorktreePath,
+      activeWorktreePath: latest.worktreePath,
       refName,
     });
 
+    if (
+      isWorktreeChangeBlocked({
+        runtime: latest.runtime,
+        currentWorktreePath: latest.worktreePath,
+        nextWorktreePath: selectionTarget.nextWorktreePath,
+      })
+    )
+      return;
+
     if (selectionTarget.reuseExistingWorktree) {
       setThreadBranch(refName.name, selectionTarget.nextWorktreePath);
       setIsBranchMenuOpen(false);
@@ -423,8 +470,11 @@
         const nextBranchName = refName.isRemote
           ? (checkoutResult.value.refName ?? selectedBranchName)
           : selectedBranchName;
+        setOptimisticBranch(
+          setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath)
+            ? nextBranchName
+            : previousBranch,
-        setOptimisticBranch(nextBranchName);
-        setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
+        );
         return;
       }
       setOptimisticBranch(previousBranch);
@@ -460,8 +510,10 @@
         },
       });
       if (createBranchResult._tag === "Success") {
+        const nextBranchName = createBranchResult.value.refName;
+        setOptimisticBranch(
-        setOptimisticBranch(createBranchResult.value.refName);
-        setThreadBranch(createBranchResult.value.refName, activeWorktreePath);
+          setThreadBranch(nextBranchName, activeWorktreePath) ? nextBranchName : previousBranch,
+        );
         return;
       }
       setOptimisticBranch(previousBranch);
@@ -633,7 +685,8 @@
                 activeProjectCwd,
                 activeWorktreePath,
                 refName,
+              }).reuseExistingWorktree)) ||
-              }).reuseExistingWorktree))
+          isWorktreeChangeBlockedForRef(refName)
         }
         onClick={() => selectPickerItem(itemValue)}
         onContextMenu={(event) => handleBranchContextMenu(event, itemValue)}
@@ -656,6 +709,11 @@
       isFetchingNextPage={isFetchingNextPage}
       onLoadNext={branchRefState.loadNext}
       statusText={branchStatusText}
+      notice={
+        refs.some((refName) => isWorktreeChangeBlockedForRef(refName))
+          ? WORKTREE_CHANGE_BLOCKED_MESSAGE
+          : null
+      }
       renderItem={renderPickerItem}
       getItemType={(item) =>
         item === checkoutPullRequestItemValue
```
