authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-07-23 00:13:21+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 01:13:21+02:00
log2d31cb022dee43e5a729273a6936228f30077e29
treef08e2de0e6e4ba38a21a95d7757504ae8a9d7592
parentbc9428a06a60db59cfddb99c6d7044a9db47eb1c
signature Signed by PGP key B5690EEEBB952194

improve and prevent silent thread branch drift and PR fetching (#2284)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

15 files changed, 699 insertions(+), 59 deletions(-)

apps/server/src/git/GitManager.test.ts+118
......@@ -8,6 +8,7 @@ import { it } from "@effect/vitest";
88import * as Effect from "effect/Effect";
99import * as FileSystem from "effect/FileSystem";
1010import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
1112import * as PlatformError from "effect/PlatformError";
1213import * as Scope from "effect/Scope";
1314import { ChildProcessSpawner } from "effect/unstable/process";
......@@ -2413,6 +2414,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
24132414 12_000,
24142415 );
24152416
2417 it.effect(
2418 "does not reuse a cross-repo PR when GitHub omits head identity metadata",
2419 () =>
2420 Effect.gen(function* () {
2421 const repoDir = yield* makeTempDir("t3code-git-manager-");
2422 yield* initRepo(repoDir);
2423 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
2424 const forkDir = yield* createBareRemote();
2425 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2426 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
2427 yield* runGit(repoDir, [
2428 "config",
2429 "remote.fork-seed.url",
2430 "git@github.com:octocat/codething-mvp.git",
2431 ]);
2432
2433 const { manager, ghCalls } = yield* makeManager({
2434 ghScenario: {
2435 prListSequenceByHeadSelector: {
2436 "octocat:statemachine": [
2437 `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`,
2438 `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`,
2439 ],
2440 "fork-seed:statemachine": ["[]"],
2441 statemachine: ["[]"],
2442 },
2443 },
2444 });
2445
2446 const result = yield* runStackedAction(manager, {
2447 cwd: repoDir,
2448 action: "commit_push_pr",
2449 });
2450
2451 expect(result.pr.status).toBe("created");
2452 expect(result.pr.number).toBe(142);
2453 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true);
2454 }),
2455 20_000,
2456 );
2457
2458 it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>
2459 Effect.sync(() => {
2460 const headContext = {
2461 headBranch: "statemachine",
2462 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
2463 headRepositoryOwnerLogin: "pingdotgg",
2464 isCrossRepository: true,
2465 };
2466
2467 expect(
2468 GitManager.matchesBranchHeadContext(
2469 {
2470 number: 41,
2471 title: "Same-repo PR",
2472 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
2473 baseRefName: "main",
2474 headRefName: "statemachine",
2475 state: "open",
2476 updatedAt: Option.none(),
2477 isCrossRepository: false,
2478 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
2479 headRepositoryOwnerLogin: "pingdotgg",
2480 },
2481 headContext,
2482 ),
2483 ).toBe(false);
2484
2485 expect(
2486 GitManager.matchesBranchHeadContext(
2487 {
2488 number: 142,
2489 title: "Fork PR",
2490 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
2491 baseRefName: "main",
2492 headRefName: "statemachine",
2493 state: "open",
2494 updatedAt: Option.none(),
2495 isCrossRepository: true,
2496 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
2497 headRepositoryOwnerLogin: "pingdotgg",
2498 },
2499 headContext,
2500 ),
2501 ).toBe(true);
2502 }),
2503 );
2504
2505 it.effect("accepts fork PR metadata when origin is the fork checkout remote", () =>
2506 Effect.sync(() => {
2507 const headContext = {
2508 headBranch: "t3code/git-audit-stability",
2509 headRepositoryNameWithOwner: "justsomelegs/t3code",
2510 headRepositoryOwnerLogin: "justsomelegs",
2511 isCrossRepository: false,
2512 };
2513
2514 expect(
2515 GitManager.matchesBranchHeadContext(
2516 {
2517 number: 2284,
2518 title: "Improve branch mismatch warnings",
2519 url: "https://github.com/pingdotgg/t3code/pull/2284",
2520 baseRefName: "main",
2521 headRefName: "t3code/git-audit-stability",
2522 state: "open",
2523 updatedAt: Option.none(),
2524 isCrossRepository: true,
2525 headRepositoryNameWithOwner: "justsomelegs/t3code",
2526 headRepositoryOwnerLogin: "justsomelegs",
2527 },
2528 headContext,
2529 ),
2530 ).toBe(true);
2531 }),
2532 );
2533
24162534 it.effect("creates PR when one does not already exist", () =>
24172535 Effect.gen(function* () {
24182536 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+64-26
......@@ -252,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner(
252252 return `${ownerLogin}/${repositoryName}`;
253253}
254254
255function matchesBranchHeadContext(
255interface PullRequestHeadIdentity {
256 readonly repositoryNameWithOwner: string | null;
257 readonly ownerLogin: string | null;
258}
259
260function resolveExpectedHeadIdentity(
261 headContext: Pick<BranchHeadContext, "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin">,
262): PullRequestHeadIdentity {
263 const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner(
264 headContext.headRepositoryNameWithOwner,
265 );
266 return {
267 repositoryNameWithOwner,
268 ownerLogin:
269 normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
270 parseRepositoryOwnerLogin(repositoryNameWithOwner),
271 };
272}
273
274function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity {
275 const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner(
276 resolvePullRequestHeadRepositoryNameWithOwner(pr),
277 );
278 return {
279 repositoryNameWithOwner,
280 ownerLogin:
281 normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
282 parseRepositoryOwnerLogin(repositoryNameWithOwner),
283 };
284}
285
286export function matchesBranchHeadContext(
256287 pr: PullRequestInfo,
257288 headContext: Pick<
258289 BranchHeadContext,
......@@ -263,44 +294,51 @@ function matchesBranchHeadContext(
263294 return false;
264295 }
265296
266 const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner(
267 headContext.headRepositoryNameWithOwner,
268 );
269 const expectedHeadOwner =
270 normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
271 parseRepositoryOwnerLogin(expectedHeadRepository);
272 const prHeadRepository = normalizeOptionalRepositoryNameWithOwner(
273 resolvePullRequestHeadRepositoryNameWithOwner(pr),
274 );
275 const prHeadOwner =
276 normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
277 parseRepositoryOwnerLogin(prHeadRepository);
297 const expectedHead = resolveExpectedHeadIdentity(headContext);
298 const pullRequestHead = resolvePullRequestHeadIdentity(pr);
278299
279 if (headContext.isCrossRepository) {
280 if (pr.isCrossRepository === false) {
281 return false;
300 if (expectedHead.repositoryNameWithOwner) {
301 if (pullRequestHead.repositoryNameWithOwner) {
302 if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) {
303 return false;
304 }
305 }
306 if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
307 if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
308 return false;
309 }
282310 }
283 if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {
311 }
312
313 if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
314 if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
284315 return false;
285316 }
286 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
317 }
318
319 if (headContext.isCrossRepository) {
320 if (pr.isCrossRepository === false) {
287321 return false;
288322 }
289 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
323 if (
324 (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) &&
325 !pullRequestHead.repositoryNameWithOwner &&
326 !pullRequestHead.ownerLogin
327 ) {
290328 return false;
291329 }
292330 return true;
293331 }
294332
295333 if (pr.isCrossRepository === true) {
296 return false;
297 }
298 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
299 return false;
300 }
301 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
302 return false;
334 if (
335 (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) ||
336 (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin)
337 ) {
338 return false;
339 }
303340 }
341
304342 return true;
305343}
306344
apps/web/src/components/BranchToolbar.logic.test.ts+50
......@@ -11,6 +11,7 @@ import {
1111 resolveEnvModeLabel,
1212 resolveBranchToolbarValue,
1313 resolveLockedWorkspaceLabel,
14 resolveLocalCheckoutBranchMismatch,
1415 shouldIncludeBranchPickerItem,
1516 shouldShowEnvironmentIndicator,
1617} from "./BranchToolbar.logic";
......@@ -85,6 +86,55 @@ describe("resolveBranchToolbarValue", () => {
8586 });
8687});
8788
89describe("resolveLocalCheckoutBranchMismatch", () => {
90 it("detects when a local thread is associated with a different branch than the checkout", () => {
91 expect(
92 resolveLocalCheckoutBranchMismatch({
93 effectiveEnvMode: "local",
94 activeWorktreePath: null,
95 activeThreadBranch: "feature/thread",
96 currentGitBranch: "feature/current",
97 }),
98 ).toEqual({
99 threadBranch: "feature/thread",
100 currentBranch: "feature/current",
101 });
102 });
103
104 it("ignores matching local checkout state", () => {
105 expect(
106 resolveLocalCheckoutBranchMismatch({
107 effectiveEnvMode: "local",
108 activeWorktreePath: null,
109 activeThreadBranch: "feature/thread",
110 currentGitBranch: "feature/thread",
111 }),
112 ).toBeNull();
113 });
114
115 it("ignores dedicated worktrees because their checkout is already thread-scoped", () => {
116 expect(
117 resolveLocalCheckoutBranchMismatch({
118 effectiveEnvMode: "worktree",
119 activeWorktreePath: "/repo/.t3/worktrees/feature-thread",
120 activeThreadBranch: "feature/thread",
121 currentGitBranch: "feature/current",
122 }),
123 ).toBeNull();
124 });
125
126 it("ignores new-worktree base selection before a worktree exists", () => {
127 expect(
128 resolveLocalCheckoutBranchMismatch({
129 effectiveEnvMode: "worktree",
130 activeWorktreePath: null,
131 activeThreadBranch: "feature/base",
132 currentGitBranch: "main",
133 }),
134 ).toBeNull();
135 });
136});
137
88138describe("resolveEnvironmentOptionLabel", () => {
89139 it("prefers the primary environment's machine label", () => {
90140 expect(
apps/web/src/components/BranchToolbar.logic.ts+16
......@@ -108,6 +108,22 @@ export function resolveBranchToolbarValue(input: {
108108 return currentGitBranch ?? activeThreadBranch;
109109}
110110
111export function resolveLocalCheckoutBranchMismatch(input: {
112 effectiveEnvMode: EnvMode;
113 activeWorktreePath: string | null;
114 activeThreadBranch: string | null;
115 currentGitBranch: string | null;
116}): { threadBranch: string; currentBranch: string } | null {
117 const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
118 if (effectiveEnvMode !== "local" || activeWorktreePath !== null) {
119 return null;
120 }
121 if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) {
122 return null;
123 }
124 return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch };
125}
126
111127export function resolveBranchSelectionTarget(input: {
112128 activeProjectCwd: string;
113129 activeWorktreePath: string | null;
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-1
......@@ -473,6 +473,7 @@ export function BranchToolbarBranchSelector({
473473 const worktreeBaseBranchCandidate = isInitialBranchesLoadPending
474474 ? null
475475 : (defaultBranchName ?? currentGitBranch);
476
476477 useEffect(() => {
477478 if (
478479 effectiveEnvMode !== "worktree" ||
......@@ -589,7 +590,11 @@ export function BranchToolbarBranchSelector({
589590 });
590591
591592 // PR pill shown next to the branch selector when the active branch has one.
592 const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null);
593 const branchPr = resolveThreadPr({
594 threadBranch: resolvedActiveBranch,
595 gitStatus: branchStatusQuery.data ?? null,
596 hasDedicatedWorktree: activeWorktreePath !== null,
597 });
593598 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
594599 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
595600 // state-description tooltip.
apps/web/src/components/ChatView.logic.test.ts+29
......@@ -20,6 +20,7 @@ import {
2020 hasServerAcknowledgedLocalDispatch,
2121 reconcileMountedTerminalThreadIds,
2222 reconcileRetainedMountedThreadIds,
23 resolveThreadMetadataUpdateForNextTurn,
2324 resolveSendEnvMode,
2425 shouldWriteThreadErrorToCurrentServerThread,
2526} from "./ChatView.logic";
......@@ -79,6 +80,34 @@ const readySession = {
7980 updatedAt: "2026-03-29T00:00:10.000Z",
8081};
8182
83describe("resolveThreadMetadataUpdateForNextTurn", () => {
84 const modelSelection = {
85 instanceId: ProviderInstanceId.make("codex"),
86 model: "gpt-5.4",
87 };
88
89 it("updates a stale local thread branch to the active checkout", () => {
90 expect(
91 resolveThreadMetadataUpdateForNextTurn({
92 currentModelSelection: modelSelection,
93 currentBranch: "feature/thread",
94 nextBranch: "feature/checkout",
95 }),
96 ).toEqual({ branch: "feature/checkout", worktreePath: null });
97 });
98
99 it("does not write metadata when the model and branch are unchanged", () => {
100 expect(
101 resolveThreadMetadataUpdateForNextTurn({
102 currentModelSelection: modelSelection,
103 nextModelSelection: modelSelection,
104 currentBranch: "feature/current",
105 nextBranch: "feature/current",
106 }),
107 ).toBeNull();
108 });
109});
110
82111describe("buildThreadTurnInterruptInput", () => {
83112 it("targets the session's active running turn", () => {
84113 const activeTurnId = TurnId.make("turn-running");
apps/web/src/components/ChatView.logic.ts+27
......@@ -27,6 +27,33 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
2727
2828export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2929
30export function resolveThreadMetadataUpdateForNextTurn(input: {
31 currentModelSelection: ModelSelection;
32 nextModelSelection?: ModelSelection;
33 currentBranch: string | null;
34 nextBranch?: string;
35}): {
36 modelSelection?: ModelSelection;
37 branch?: string;
38 worktreePath?: null;
39} | null {
40 const nextModelSelection = input.nextModelSelection;
41 const modelSelectionChanged =
42 nextModelSelection !== undefined &&
43 (nextModelSelection.model !== input.currentModelSelection.model ||
44 nextModelSelection.instanceId !== input.currentModelSelection.instanceId ||
45 JSON.stringify(nextModelSelection.options ?? null) !==
46 JSON.stringify(input.currentModelSelection.options ?? null));
47 const branchChanged = input.nextBranch !== undefined && input.nextBranch !== input.currentBranch;
48 if (!modelSelectionChanged && !branchChanged) {
49 return null;
50 }
51 return {
52 ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}),
53 ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}),
54 };
55}
56
3057export function buildLocalDraftThread(
3158 threadId: ThreadId,
3259 draftThread: DraftThreadState,
apps/web/src/components/ChatView.tsx+197-10
......@@ -212,7 +212,7 @@ import { ChatHeader } from "./chat/ChatHeader";
212212import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
213213import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
214214import { NoActiveThreadState } from "./NoActiveThreadState";
215import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";
215import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
216216import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
217217import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
218218import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
......@@ -242,6 +242,7 @@ import {
242242 deriveLockedProvider,
243243 readFileAsDataUrl,
244244 reconcileMountedTerminalThreadIds,
245 resolveThreadMetadataUpdateForNextTurn,
245246 resolveSendEnvMode,
246247 revokeBlobPreviewUrl,
247248 revokeUserMessagePreviewUrls,
......@@ -1080,6 +1081,10 @@ type LocalThreadErrorEntry = {
10801081 readonly at: number;
10811082};
10821083
1084function chatActionErrorMessage(error: unknown): string {
1085 return error instanceof Error ? error.message : "An error occurred.";
1086}
1087
10831088function ChatViewContent(props: ChatViewProps) {
10841089 const {
10851090 environmentId,
......@@ -1107,6 +1112,7 @@ function ChatViewContent(props: ChatViewProps) {
11071112 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
11081113 reportFailure: false,
11091114 });
1115 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
11101116 const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
11111117 reportFailure: false,
11121118 });
......@@ -1790,7 +1796,7 @@ function ChatViewContent(props: ChatViewProps) {
17901796 const versionMismatchEnvironmentId =
17911797 versionMismatch && activeThread ? activeThread.environmentId : null;
17921798 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
1793 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
1799 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
17941800 const items: ComposerBannerStackItem[] = [];
17951801 if (activeEnvironmentUnavailableState) {
17961802 const connection = activeEnvironmentUnavailableState.connection;
......@@ -3250,6 +3256,7 @@ function ChatViewContent(props: ChatViewProps) {
32503256 threadId: ThreadId;
32513257 createdAt: string;
32523258 modelSelection?: ModelSelection;
3259 branch?: string;
32533260 runtimeMode: RuntimeMode;
32543261 interactionMode: ProviderInteractionMode;
32553262 }): Promise<AtomCommandResult<void, unknown>> => {
......@@ -3258,19 +3265,19 @@ function ChatViewContent(props: ChatViewProps) {
32583265 }
32593266
32603267 let result: AtomCommandResult<void, unknown> = AsyncResult.success(undefined);
3261 if (
3262 input.modelSelection !== undefined &&
3263 (input.modelSelection.model !== serverThread.modelSelection.model ||
3264 input.modelSelection.instanceId !== serverThread.modelSelection.instanceId ||
3265 JSON.stringify(input.modelSelection.options ?? null) !==
3266 JSON.stringify(serverThread.modelSelection.options ?? null))
3267 ) {
3268 const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({
3269 currentModelSelection: serverThread.modelSelection,
3270 ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}),
3271 currentBranch: serverThread.branch,
3272 ...(input.branch ? { nextBranch: input.branch } : {}),
3273 });
3274 if (metadataUpdate) {
32683275 result = mapAtomCommandResult(
32693276 await updateThreadMetadata({
32703277 environmentId,
32713278 input: {
32723279 threadId: input.threadId,
3273 modelSelection: input.modelSelection,
3280 ...metadataUpdate,
32743281 },
32753282 }),
32763283 () => undefined,
......@@ -3764,6 +3771,179 @@ function ChatViewContent(props: ChatViewProps) {
37643771 requestedEnvMode: envMode,
37653772 isGitRepo,
37663773 });
3774 const localCheckoutBranchMismatch = useMemo(
3775 () =>
3776 isServerThread
3777 ? resolveLocalCheckoutBranchMismatch({
3778 effectiveEnvMode: envMode,
3779 activeWorktreePath,
3780 activeThreadBranch,
3781 currentGitBranch: gitStatusQuery.data?.refName ?? null,
3782 })
3783 : null,
3784 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
3785 );
3786 const [branchRepairAction, setBranchRepairAction] = useState<
3787 "update-thread" | "switch-checkout" | null
3788 >(null);
3789 const handleUpdateThreadToCheckout = useCallback(async () => {
3790 if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) {
3791 return;
3792 }
3793 setBranchRepairAction("update-thread");
3794 const updateResult = await updateThreadMetadata({
3795 environmentId,
3796 input: {
3797 threadId: activeThread.id,
3798 branch: localCheckoutBranchMismatch.currentBranch,
3799 worktreePath: null,
3800 },
3801 });
3802 setBranchRepairAction(null);
3803 if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) {
3804 toastManager.add(
3805 stackedThreadToast({
3806 type: "error",
3807 title: "Failed to update thread branch",
3808 description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)),
3809 }),
3810 );
3811 return;
3812 }
3813 scheduleComposerFocus();
3814 }, [
3815 activeThread,
3816 branchRepairAction,
3817 environmentId,
3818 localCheckoutBranchMismatch,
3819 scheduleComposerFocus,
3820 updateThreadMetadata,
3821 ]);
3822 const handleSwitchCheckoutToThread = useCallback(async () => {
3823 if (
3824 !activeProjectCwd ||
3825 !activeThread ||
3826 !localCheckoutBranchMismatch ||
3827 branchRepairAction !== null
3828 ) {
3829 return;
3830 }
3831 setBranchRepairAction("switch-checkout");
3832 const checkoutResult = await switchGitRef({
3833 environmentId,
3834 input: {
3835 cwd: activeProjectCwd,
3836 refName: localCheckoutBranchMismatch.threadBranch,
3837 },
3838 });
3839 if (checkoutResult._tag === "Failure") {
3840 setBranchRepairAction(null);
3841 if (!isAtomCommandInterrupted(checkoutResult)) {
3842 toastManager.add(
3843 stackedThreadToast({
3844 type: "error",
3845 title: "Failed to switch checkout",
3846 description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)),
3847 }),
3848 );
3849 }
3850 return;
3851 }
3852
3853 const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch;
3854 if (nextBranch !== activeThread.branch) {
3855 const updateResult = await updateThreadMetadata({
3856 environmentId,
3857 input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null },
3858 });
3859 if (updateResult._tag === "Failure") {
3860 setBranchRepairAction(null);
3861 if (!isAtomCommandInterrupted(updateResult)) {
3862 toastManager.add(
3863 stackedThreadToast({
3864 type: "error",
3865 title: "Checkout switched, but the thread could not be updated",
3866 description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)),
3867 }),
3868 );
3869 }
3870 gitStatusQuery.refresh();
3871 return;
3872 }
3873 }
3874 gitStatusQuery.refresh();
3875 setBranchRepairAction(null);
3876 scheduleComposerFocus();
3877 }, [
3878 activeProjectCwd,
3879 activeThread,
3880 branchRepairAction,
3881 environmentId,
3882 gitStatusQuery,
3883 localCheckoutBranchMismatch,
3884 scheduleComposerFocus,
3885 switchGitRef,
3886 updateThreadMetadata,
3887 ]);
3888 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
3889 if (!localCheckoutBranchMismatch) {
3890 return systemComposerBannerItems;
3891 }
3892 const isRepairingBranch = branchRepairAction !== null;
3893 return [
3894 ...systemComposerBannerItems,
3895 {
3896 id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`,
3897 variant: "warning",
3898 icon: <TriangleAlertIcon />,
3899 title: "You're on a different branch",
3900 className:
3901 "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none",
3902 actionClassName:
3903 "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3",
3904 description: (
3905 <p className="text-pretty">
3906 This thread is on{" "}
3907 <code className="font-medium text-foreground">
3908 {localCheckoutBranchMismatch.threadBranch}
3909 </code>
3910 , but you're currently checked out at{" "}
3911 <code className="font-medium text-foreground">
3912 {localCheckoutBranchMismatch.currentBranch}
3913 </code>
3914 . Sending a message will update the thread.
3915 </p>
3916 ),
3917 actions: (
3918 <>
3919 <Button
3920 size="xs"
3921 variant="outline"
3922 disabled={isRepairingBranch}
3923 onClick={() => void handleUpdateThreadToCheckout()}
3924 >
3925 {branchRepairAction === "update-thread" ? "Moving..." : "Move thread here"}
3926 </Button>
3927 <Button
3928 size="xs"
3929 variant="ghost"
3930 disabled={isRepairingBranch}
3931 onClick={() => void handleSwitchCheckoutToThread()}
3932 >
3933 {branchRepairAction === "switch-checkout" ? "Switching..." : "Checkout thread branch"}
3934 </Button>
3935 </>
3936 ),
3937 },
3938 ];
3939 }, [
3940 activeThread?.id,
3941 branchRepairAction,
3942 handleSwitchCheckoutToThread,
3943 handleUpdateThreadToCheckout,
3944 localCheckoutBranchMismatch,
3945 systemComposerBannerItems,
3946 ]);
37673947
37683948 useEffect(() => {
37693949 setPendingServerThreadEnvMode(null);
......@@ -4313,6 +4493,9 @@ function ChatViewContent(props: ChatViewProps) {
43134493 threadId: threadIdForSend,
43144494 createdAt: messageCreatedAt,
43154495 ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}),
4496 ...(localCheckoutBranchMismatch
4497 ? { branch: localCheckoutBranchMismatch.currentBranch }
4498 : {}),
43164499 runtimeMode,
43174500 interactionMode,
43184501 });
......@@ -4694,6 +4877,9 @@ function ChatViewContent(props: ChatViewProps) {
46944877 threadId: threadIdForSend,
46954878 createdAt: messageCreatedAt,
46964879 modelSelection: ctxSelectedModelSelection,
4880 ...(localCheckoutBranchMismatch
4881 ? { branch: localCheckoutBranchMismatch.currentBranch }
4882 : {}),
46974883 runtimeMode,
46984884 interactionMode: nextInteractionMode,
46994885 });
......@@ -4770,6 +4956,7 @@ function ChatViewContent(props: ChatViewProps) {
47704956 isConnecting,
47714957 isSendBusy,
47724958 isServerThread,
4959 localCheckoutBranchMismatch,
47734960 persistThreadSettingsForNextTurn,
47744961 resetLocalDispatch,
47754962 runtimeMode,
apps/web/src/components/GitActionsControl.tsx+3-3
......@@ -1117,12 +1117,12 @@ export default function GitActionsControl({
11171117 activeDraftThread.worktreePath === null;
11181118
11191119 useEffect(() => {
1120 if (isGitActionRunning || isSelectingWorktreeBase) {
1120 if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) {
11211121 return;
11221122 }
11231123
11241124 const branchUpdate = resolveLiveThreadBranchUpdate({
1125 threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null,
1125 threadBranch: activeDraftThread?.branch ?? null,
11261126 gitStatus: gitStatusForActions,
11271127 });
11281128 if (!branchUpdate) {
......@@ -1131,7 +1131,7 @@ export default function GitActionsControl({
11311131
11321132 persistThreadBranchSync(branchUpdate.branch);
11331133 }, [
1134 activeServerThread?.branch,
1134 activeServerThread,
11351135 activeDraftThread?.branch,
11361136 gitStatusForActions,
11371137 isGitActionRunning,
apps/web/src/components/Sidebar.tsx+9-2
......@@ -15,6 +15,7 @@ import {
1515import {
1616 ChangeRequestStatusIcon,
1717 prStatusIndicator,
18 PrStatusTooltipContent,
1819 resolveThreadPr,
1920 terminalStatusFromRunningIds,
2021 ThreadStatusLabel,
......@@ -460,7 +461,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
460461 lastVisitedAt,
461462 },
462463 });
463 const pr = resolveThreadPr(thread.branch, gitStatus.data);
464 const pr = resolveThreadPr({
465 threadBranch: thread.branch,
466 gitStatus: gitStatus.data,
467 hasDedicatedWorktree: thread.worktreePath !== null,
468 });
464469 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
465470 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
466471 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
......@@ -700,7 +705,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
700705 </button>
701706 }
702707 />
703 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
708 <TooltipPopup side="top">
709 <PrStatusTooltipContent status={prStatus} />
710 </TooltipPopup>
704711 </Tooltip>
705712 )}
706713 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
apps/web/src/components/SidebarV2.tsx+27-2
......@@ -83,6 +83,7 @@ import {
8383 resolveSidebarV2Status,
8484 sortThreadsForSidebarV2,
8585} from "./Sidebar.logic";
86import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
8687import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
8788import { ProjectFavicon } from "./ProjectFavicon";
8889import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
......@@ -127,6 +128,7 @@ function SidebarV2ThreadTooltip({
127128 modelInstanceId,
128129 modelLabel,
129130 status,
131 branchMismatch,
130132}: {
131133 thread: SidebarThreadSummary;
132134 projectTitle: string | null;
......@@ -140,6 +142,10 @@ function SidebarV2ThreadTooltip({
140142 className: string;
141143 icon: "working" | "done" | null;
142144 } | null;
145 branchMismatch: {
146 threadBranch: string;
147 currentBranch: string;
148 } | null;
143149}) {
144150 return (
145151 <TooltipPopup
......@@ -200,6 +206,14 @@ function SidebarV2ThreadTooltip({
200206 </div>
201207 </div>
202208 ) : null}
209 {branchMismatch ? (
210 <div className="flex min-w-0 items-start gap-2 text-warning">
211 <CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 stroke-current" />
212 <div className="min-w-0 flex-1 wrap-break-word leading-5">
213 You're currently checked out on another branch.
214 </div>
215 </div>
216 ) : null}
203217 {driverKind ? (
204218 <div className="flex min-w-0 items-start gap-2">
205219 <ProviderInstanceIcon
......@@ -321,14 +335,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
321335
322336 const gitCwd = thread.worktreePath ?? props.projectCwd;
323337 const gitStatus = useEnvironmentQuery(
324 thread.branch != null && gitCwd !== null
338 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
325339 ? vcsEnvironment.status({
326340 environmentId: thread.environmentId,
327341 input: { cwd: gitCwd },
328342 })
329343 : null,
330344 );
331 const pr = resolveThreadPr(thread.branch, gitStatus.data);
345 const branchMismatch = resolveLocalCheckoutBranchMismatch({
346 effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",
347 activeWorktreePath: thread.worktreePath,
348 activeThreadBranch: thread.branch,
349 currentGitBranch: gitStatus.data?.refName ?? null,
350 });
351 const pr = resolveThreadPr({
352 threadBranch: thread.branch,
353 gitStatus: gitStatus.data,
354 hasDedicatedWorktree: thread.worktreePath !== null,
355 });
332356 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
333357 // Report the PR state up: the parent partitions rows with effectiveSettled,
334358 // and a merged/closed PR auto-settles a thread — data only rows have.
......@@ -360,6 +384,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
360384 modelInstanceId={modelInstanceId}
361385 modelLabel={modelLabel}
362386 status={topStatus}
387 branchMismatch={branchMismatch}
363388 />
364389 );
365390
apps/web/src/components/ThreadStatusIndicators.test.ts created+73
......@@ -0,0 +1,73 @@
1import type { VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
5
6function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
7 return {
8 isRepo: true,
9 hasPrimaryRemote: true,
10 isDefaultRef: false,
11 refName: "feature/current",
12 hasWorkingTreeChanges: false,
13 workingTree: { files: [], insertions: 0, deletions: 0 },
14 hasUpstream: true,
15 aheadCount: 0,
16 behindCount: 0,
17 pr: {
18 number: 42,
19 title: "PR branch",
20 url: "https://github.com/pingdotgg/t3code/pull/42",
21 baseRef: "main",
22 headRef: "feature/current",
23 state: "open",
24 },
25 ...overrides,
26 };
27}
28
29describe("resolveThreadPr", () => {
30 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
31 expect(
32 resolveThreadPr({
33 threadBranch: "feature/other",
34 gitStatus: status(),
35 hasDedicatedWorktree: false,
36 }),
37 ).toBeNull();
38 });
39
40 it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => {
41 const gitStatus = status();
42
43 expect(
44 resolveThreadPr({
45 threadBranch: "feature/old-name",
46 gitStatus,
47 hasDedicatedWorktree: true,
48 }),
49 ).toBe(gitStatus.pr);
50 });
51
52 it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => {
53 const gitStatus = status();
54
55 expect(
56 resolveThreadPr({
57 threadBranch: null,
58 gitStatus,
59 hasDedicatedWorktree: true,
60 }),
61 ).toBe(gitStatus.pr);
62 });
63});
64
65describe("prStatusIndicator", () => {
66 it("formats PR tooltips with number, uppercase status, and title", () => {
67 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({
68 tooltip: "PR #42 - Open: PR branch",
69 tooltipLead: "PR #42 - Open",
70 tooltipTitle: "PR branch",
71 });
72 });
73});
apps/web/src/components/ThreadStatusIndicators.tsx+55-11
......@@ -22,6 +22,8 @@ export interface PrStatusIndicator {
2222 label: string;
2323 colorClass: string;
2424 tooltip: string;
25 tooltipLead: string;
26 tooltipTitle: string;
2527 url: string;
2628}
2729
......@@ -37,14 +39,26 @@ export function prStatusIndicator(
3739 pr: ThreadPr,
3840 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
3941): PrStatusIndicator | null {
42 function formatPrState(state: NonNullable<ThreadPr>["state"]): string {
43 return state.charAt(0).toUpperCase() + state.slice(1);
44 }
45
46 function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {
47 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`;
48 }
4049 if (!pr) return null;
4150 const presentation = resolveChangeRequestPresentation(provider);
4251
52 const tooltipLead = formatPrStatusLead(pr, presentation.shortName);
53 const tooltip = `${tooltipLead}: ${pr.title}`;
54
4355 if (pr.state === "open") {
4456 return {
4557 label: `${presentation.shortName} open`,
4658 colorClass: "text-emerald-600 dark:text-emerald-300/90",
47 tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`,
59 tooltip,
60 tooltipLead,
61 tooltipTitle: pr.title,
4862 url: pr.url,
4963 };
5064 }
......@@ -52,7 +66,9 @@ export function prStatusIndicator(
5266 return {
5367 label: `${presentation.shortName} closed`,
5468 colorClass: "text-zinc-500 dark:text-zinc-400/80",
55 tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`,
69 tooltip,
70 tooltipLead,
71 tooltipTitle: pr.title,
5672 url: pr.url,
5773 };
5874 }
......@@ -60,7 +76,9 @@ export function prStatusIndicator(
6076 return {
6177 label: `${presentation.shortName} merged`,
6278 colorClass: "text-violet-600 dark:text-violet-300/90",
63 tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`,
79 tooltip,
80 tooltipLead,
81 tooltipTitle: pr.title,
6482 url: pr.url,
6583 };
6684 }
......@@ -71,11 +89,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) {
7189 return <GitPullRequestIcon className={className} />;
7290}
7391
74export function resolveThreadPr(
75 threadBranch: string | null,
76 gitStatus: VcsStatusResult | null,
77): ThreadPr | null {
78 if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) {
92export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) {
93 return (
94 <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap">
95 <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span>
96 <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" />
97 <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span>
98 </span>
99 );
100}
101
102export function resolveThreadPr(input: {
103 threadBranch: string | null;
104 gitStatus: VcsStatusResult | null;
105 hasDedicatedWorktree: boolean;
106}): ThreadPr | null {
107 const { threadBranch, gitStatus, hasDedicatedWorktree } = input;
108 if (gitStatus === null) {
109 return null;
110 }
111
112 if (hasDedicatedWorktree) {
113 return gitStatus.pr ?? null;
114 }
115
116 if (threadBranch === null || gitStatus.refName !== threadBranch) {
79117 return null;
80118 }
81119
......@@ -199,14 +237,18 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
199237 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
200238 const gitCwd = thread.worktreePath ?? threadProjectCwd;
201239 const gitStatus = useEnvironmentQuery(
202 thread.branch != null && gitCwd !== null
240 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
203241 ? vcsEnvironment.status({
204242 environmentId: thread.environmentId,
205243 input: { cwd: gitCwd },
206244 })
207245 : null,
208246 );
209 const pr = resolveThreadPr(thread.branch, gitStatus.data);
247 const pr = resolveThreadPr({
248 threadBranch: thread.branch,
249 gitStatus: gitStatus.data,
250 hasDedicatedWorktree: thread.worktreePath !== null,
251 });
210252 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
211253 const threadStatus = resolveThreadStatusPill({
212254 thread: {
......@@ -233,7 +275,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
233275 >
234276 <ChangeRequestStatusIcon className="size-3" />
235277 </TooltipTrigger>
236 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
278 <TooltipPopup side="top">
279 <PrStatusTooltipContent status={prStatus} />
280 </TooltipPopup>
237281 </Tooltip>
238282 ) : null}
239283 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
apps/web/src/components/chat/ComposerBannerStack.test.tsx+18
......@@ -34,4 +34,22 @@ describe("ComposerBannerStack", () => {
3434
3535 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
3636 });
37
38 it("applies item-specific surface and action layout classes", () => {
39 const markup = renderToStaticMarkup(
40 <ComposerBannerStack
41 items={[
42 {
43 ...banner("branch"),
44 className: "branch-surface",
45 actionClassName: "branch-actions",
46 actions: <button type="button">Repair</button>,
47 },
48 ]}
49 />,
50 );
51
52 expect(markup).toContain("branch-surface");
53 expect(markup).toContain("branch-actions");
54 });
3755});
apps/web/src/components/chat/ComposerBannerStack.tsx+7-4
......@@ -30,6 +30,8 @@ export interface ComposerBannerStackItem {
3030 readonly title: ReactNode;
3131 readonly description?: ReactNode;
3232 readonly actions?: ReactNode;
33 readonly className?: string;
34 readonly actionClassName?: string;
3335 readonly dismissLabel?: string;
3436 readonly onDismiss?: () => void;
3537}
......@@ -171,17 +173,18 @@ function ComposerBannerStackAlert({
171173 const dismissOnly = item.onDismiss && !item.actions;
172174
173175 return (
174 <Alert variant={item.variant}>
176 <Alert variant={item.variant} className={item.className}>
175177 {item.icon}
176178 <AlertTitle>{item.title}</AlertTitle>
177179 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}
178180 {item.actions || item.onDismiss ? (
179181 <AlertAction
180 className={
182 className={cn(
183 item.actionClassName,
181184 dismissOnly
182185 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"
183 : undefined
184 }
186 : undefined,
187 )}
185188 >
186189 {item.actions}
187190 {item.onDismiss ? (