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";...@@ -8,6 +8,7 @@ import { it } from "@effect/vitest";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
11import * as PlatformError from "effect/PlatformError";12import * as PlatformError from "effect/PlatformError";
12import * as Scope from "effect/Scope";13import * as Scope from "effect/Scope";
13import { ChildProcessSpawner } from "effect/unstable/process";14import { ChildProcessSpawner } from "effect/unstable/process";
...@@ -2413,6 +2414,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2413,6 +2414,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2413 12_000,2414 12_000,
2414 );2415 );
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
2416 it.effect("creates PR when one does not already exist", () =>2534 it.effect("creates PR when one does not already exist", () =>
2417 Effect.gen(function* () {2535 Effect.gen(function* () {
2418 const repoDir = yield* makeTempDir("t3code-git-manager-");2536 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+64-26
...@@ -252,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner(...@@ -252,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner(
252 return `${ownerLogin}/${repositoryName}`;252 return `${ownerLogin}/${repositoryName}`;
253}253}
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(
256 pr: PullRequestInfo,287 pr: PullRequestInfo,
257 headContext: Pick<288 headContext: Pick<
258 BranchHeadContext,289 BranchHeadContext,
...@@ -263,44 +294,51 @@ function matchesBranchHeadContext(...@@ -263,44 +294,51 @@ function matchesBranchHeadContext(
263 return false;294 return false;
264 }295 }
265296
266 const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner(297 const expectedHead = resolveExpectedHeadIdentity(headContext);
267 headContext.headRepositoryNameWithOwner,298 const pullRequestHead = resolvePullRequestHeadIdentity(pr);
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);
278299
279 if (headContext.isCrossRepository) {300 if (expectedHead.repositoryNameWithOwner) {
280 if (pr.isCrossRepository === false) {301 if (pullRequestHead.repositoryNameWithOwner) {
281 return false;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 }
282 }310 }
283 if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {311 }
312
313 if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
314 if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
284 return false;315 return false;
285 }316 }
286 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {317 }
318
319 if (headContext.isCrossRepository) {
320 if (pr.isCrossRepository === false) {
287 return false;321 return false;
288 }322 }
289 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {323 if (
324 (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) &&
325 !pullRequestHead.repositoryNameWithOwner &&
326 !pullRequestHead.ownerLogin
327 ) {
290 return false;328 return false;
291 }329 }
292 return true;330 return true;
293 }331 }
294332
295 if (pr.isCrossRepository === true) {333 if (pr.isCrossRepository === true) {
296 return false;334 if (
297 }335 (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) ||
298 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {336 (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin)
299 return false;337 ) {
300 }338 return false;
301 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {339 }
302 return false;
303 }340 }
341
304 return true;342 return true;
305}343}
306344
apps/web/src/components/BranchToolbar.logic.test.ts+50
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 resolveEnvModeLabel,11 resolveEnvModeLabel,
12 resolveBranchToolbarValue,12 resolveBranchToolbarValue,
13 resolveLockedWorkspaceLabel,13 resolveLockedWorkspaceLabel,
14 resolveLocalCheckoutBranchMismatch,
14 shouldIncludeBranchPickerItem,15 shouldIncludeBranchPickerItem,
15 shouldShowEnvironmentIndicator,16 shouldShowEnvironmentIndicator,
16} from "./BranchToolbar.logic";17} from "./BranchToolbar.logic";
...@@ -85,6 +86,55 @@ describe("resolveBranchToolbarValue", () => {...@@ -85,6 +86,55 @@ describe("resolveBranchToolbarValue", () => {
85 });86 });
86});87});
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
88describe("resolveEnvironmentOptionLabel", () => {138describe("resolveEnvironmentOptionLabel", () => {
89 it("prefers the primary environment's machine label", () => {139 it("prefers the primary environment's machine label", () => {
90 expect(140 expect(
apps/web/src/components/BranchToolbar.logic.ts+16
...@@ -108,6 +108,22 @@ export function resolveBranchToolbarValue(input: {...@@ -108,6 +108,22 @@ export function resolveBranchToolbarValue(input: {
108 return currentGitBranch ?? activeThreadBranch;108 return currentGitBranch ?? activeThreadBranch;
109}109}
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
111export function resolveBranchSelectionTarget(input: {127export function resolveBranchSelectionTarget(input: {
112 activeProjectCwd: string;128 activeProjectCwd: string;
113 activeWorktreePath: string | null;129 activeWorktreePath: string | null;
apps/web/src/components/BranchToolbarBranchSelector.tsx+6-1
...@@ -473,6 +473,7 @@ export function BranchToolbarBranchSelector({...@@ -473,6 +473,7 @@ export function BranchToolbarBranchSelector({
473 const worktreeBaseBranchCandidate = isInitialBranchesLoadPending473 const worktreeBaseBranchCandidate = isInitialBranchesLoadPending
474 ? null474 ? null
475 : (defaultBranchName ?? currentGitBranch);475 : (defaultBranchName ?? currentGitBranch);
476
476 useEffect(() => {477 useEffect(() => {
477 if (478 if (
478 effectiveEnvMode !== "worktree" ||479 effectiveEnvMode !== "worktree" ||
...@@ -589,7 +590,11 @@ export function BranchToolbarBranchSelector({...@@ -589,7 +590,11 @@ export function BranchToolbarBranchSelector({
589 });590 });
590591
591 // PR pill shown next to the branch selector when the active branch has one.592 // 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 });
593 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);598 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
594 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's599 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
595 // state-description tooltip.600 // state-description tooltip.
apps/web/src/components/ChatView.logic.test.ts+29
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 hasServerAcknowledgedLocalDispatch,20 hasServerAcknowledgedLocalDispatch,
21 reconcileMountedTerminalThreadIds,21 reconcileMountedTerminalThreadIds,
22 reconcileRetainedMountedThreadIds,22 reconcileRetainedMountedThreadIds,
23 resolveThreadMetadataUpdateForNextTurn,
23 resolveSendEnvMode,24 resolveSendEnvMode,
24 shouldWriteThreadErrorToCurrentServerThread,25 shouldWriteThreadErrorToCurrentServerThread,
25} from "./ChatView.logic";26} from "./ChatView.logic";
...@@ -79,6 +80,34 @@ const readySession = {...@@ -79,6 +80,34 @@ const readySession = {
79 updatedAt: "2026-03-29T00:00:10.000Z",80 updatedAt: "2026-03-29T00:00:10.000Z",
80};81};
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
82describe("buildThreadTurnInterruptInput", () => {111describe("buildThreadTurnInterruptInput", () => {
83 it("targets the session's active running turn", () => {112 it("targets the session's active running turn", () => {
84 const activeTurnId = TurnId.make("turn-running");113 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;...@@ -27,6 +27,33 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
2727
28export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);28export 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
30export function buildLocalDraftThread(57export function buildLocalDraftThread(
31 threadId: ThreadId,58 threadId: ThreadId,
32 draftThread: DraftThreadState,59 draftThread: DraftThreadState,
apps/web/src/components/ChatView.tsx+197-10
...@@ -212,7 +212,7 @@ import { ChatHeader } from "./chat/ChatHeader";...@@ -212,7 +212,7 @@ import { ChatHeader } from "./chat/ChatHeader";
212import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";212import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
213import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";213import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
214import { NoActiveThreadState } from "./NoActiveThreadState";214import { NoActiveThreadState } from "./NoActiveThreadState";
215import { resolveEffectiveEnvMode } from "./BranchToolbar.logic";215import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
216import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";216import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
217import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";217import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
218import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";218import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
...@@ -242,6 +242,7 @@ import {...@@ -242,6 +242,7 @@ import {
242 deriveLockedProvider,242 deriveLockedProvider,
243 readFileAsDataUrl,243 readFileAsDataUrl,
244 reconcileMountedTerminalThreadIds,244 reconcileMountedTerminalThreadIds,
245 resolveThreadMetadataUpdateForNextTurn,
245 resolveSendEnvMode,246 resolveSendEnvMode,
246 revokeBlobPreviewUrl,247 revokeBlobPreviewUrl,
247 revokeUserMessagePreviewUrls,248 revokeUserMessagePreviewUrls,
...@@ -1080,6 +1081,10 @@ type LocalThreadErrorEntry = {...@@ -1080,6 +1081,10 @@ type LocalThreadErrorEntry = {
1080 readonly at: number;1081 readonly at: number;
1081};1082};
10821083
1084function chatActionErrorMessage(error: unknown): string {
1085 return error instanceof Error ? error.message : "An error occurred.";
1086}
1087
1083function ChatViewContent(props: ChatViewProps) {1088function ChatViewContent(props: ChatViewProps) {
1084 const {1089 const {
1085 environmentId,1090 environmentId,
...@@ -1107,6 +1112,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1107,6 +1112,7 @@ function ChatViewContent(props: ChatViewProps) {
1107 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1112 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1108 reportFailure: false,1113 reportFailure: false,
1109 });1114 });
1115 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
1110 const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {1116 const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {
1111 reportFailure: false,1117 reportFailure: false,
1112 });1118 });
...@@ -1790,7 +1796,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1790,7 +1796,7 @@ function ChatViewContent(props: ChatViewProps) {
1790 const versionMismatchEnvironmentId =1796 const versionMismatchEnvironmentId =
1791 versionMismatch && activeThread ? activeThread.environmentId : null;1797 versionMismatch && activeThread ? activeThread.environmentId : null;
1792 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);1798 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
1793 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {1799 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
1794 const items: ComposerBannerStackItem[] = [];1800 const items: ComposerBannerStackItem[] = [];
1795 if (activeEnvironmentUnavailableState) {1801 if (activeEnvironmentUnavailableState) {
1796 const connection = activeEnvironmentUnavailableState.connection;1802 const connection = activeEnvironmentUnavailableState.connection;
...@@ -3250,6 +3256,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3250,6 +3256,7 @@ function ChatViewContent(props: ChatViewProps) {
3250 threadId: ThreadId;3256 threadId: ThreadId;
3251 createdAt: string;3257 createdAt: string;
3252 modelSelection?: ModelSelection;3258 modelSelection?: ModelSelection;
3259 branch?: string;
3253 runtimeMode: RuntimeMode;3260 runtimeMode: RuntimeMode;
3254 interactionMode: ProviderInteractionMode;3261 interactionMode: ProviderInteractionMode;
3255 }): Promise<AtomCommandResult<void, unknown>> => {3262 }): Promise<AtomCommandResult<void, unknown>> => {
...@@ -3258,19 +3265,19 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3258,19 +3265,19 @@ function ChatViewContent(props: ChatViewProps) {
3258 }3265 }
32593266
3260 let result: AtomCommandResult<void, unknown> = AsyncResult.success(undefined);3267 let result: AtomCommandResult<void, unknown> = AsyncResult.success(undefined);
3261 if (3268 const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({
3262 input.modelSelection !== undefined &&3269 currentModelSelection: serverThread.modelSelection,
3263 (input.modelSelection.model !== serverThread.modelSelection.model ||3270 ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}),
3264 input.modelSelection.instanceId !== serverThread.modelSelection.instanceId ||3271 currentBranch: serverThread.branch,
3265 JSON.stringify(input.modelSelection.options ?? null) !==3272 ...(input.branch ? { nextBranch: input.branch } : {}),
3266 JSON.stringify(serverThread.modelSelection.options ?? null))3273 });
3267 ) {3274 if (metadataUpdate) {
3268 result = mapAtomCommandResult(3275 result = mapAtomCommandResult(
3269 await updateThreadMetadata({3276 await updateThreadMetadata({
3270 environmentId,3277 environmentId,
3271 input: {3278 input: {
3272 threadId: input.threadId,3279 threadId: input.threadId,
3273 modelSelection: input.modelSelection,3280 ...metadataUpdate,
3274 },3281 },
3275 }),3282 }),
3276 () => undefined,3283 () => undefined,
...@@ -3764,6 +3771,179 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3764,6 +3771,179 @@ function ChatViewContent(props: ChatViewProps) {
3764 requestedEnvMode: envMode,3771 requestedEnvMode: envMode,
3765 isGitRepo,3772 isGitRepo,
3766 });3773 });
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
3768 useEffect(() => {3948 useEffect(() => {
3769 setPendingServerThreadEnvMode(null);3949 setPendingServerThreadEnvMode(null);
...@@ -4313,6 +4493,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4313,6 +4493,9 @@ function ChatViewContent(props: ChatViewProps) {
4313 threadId: threadIdForSend,4493 threadId: threadIdForSend,
4314 createdAt: messageCreatedAt,4494 createdAt: messageCreatedAt,
4315 ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}),4495 ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}),
4496 ...(localCheckoutBranchMismatch
4497 ? { branch: localCheckoutBranchMismatch.currentBranch }
4498 : {}),
4316 runtimeMode,4499 runtimeMode,
4317 interactionMode,4500 interactionMode,
4318 });4501 });
...@@ -4694,6 +4877,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4694,6 +4877,9 @@ function ChatViewContent(props: ChatViewProps) {
4694 threadId: threadIdForSend,4877 threadId: threadIdForSend,
4695 createdAt: messageCreatedAt,4878 createdAt: messageCreatedAt,
4696 modelSelection: ctxSelectedModelSelection,4879 modelSelection: ctxSelectedModelSelection,
4880 ...(localCheckoutBranchMismatch
4881 ? { branch: localCheckoutBranchMismatch.currentBranch }
4882 : {}),
4697 runtimeMode,4883 runtimeMode,
4698 interactionMode: nextInteractionMode,4884 interactionMode: nextInteractionMode,
4699 });4885 });
...@@ -4770,6 +4956,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4770,6 +4956,7 @@ function ChatViewContent(props: ChatViewProps) {
4770 isConnecting,4956 isConnecting,
4771 isSendBusy,4957 isSendBusy,
4772 isServerThread,4958 isServerThread,
4959 localCheckoutBranchMismatch,
4773 persistThreadSettingsForNextTurn,4960 persistThreadSettingsForNextTurn,
4774 resetLocalDispatch,4961 resetLocalDispatch,
4775 runtimeMode,4962 runtimeMode,
apps/web/src/components/GitActionsControl.tsx+3-3
...@@ -1117,12 +1117,12 @@ export default function GitActionsControl({...@@ -1117,12 +1117,12 @@ export default function GitActionsControl({
1117 activeDraftThread.worktreePath === null;1117 activeDraftThread.worktreePath === null;
11181118
1119 useEffect(() => {1119 useEffect(() => {
1120 if (isGitActionRunning || isSelectingWorktreeBase) {1120 if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) {
1121 return;1121 return;
1122 }1122 }
11231123
1124 const branchUpdate = resolveLiveThreadBranchUpdate({1124 const branchUpdate = resolveLiveThreadBranchUpdate({
1125 threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null,1125 threadBranch: activeDraftThread?.branch ?? null,
1126 gitStatus: gitStatusForActions,1126 gitStatus: gitStatusForActions,
1127 });1127 });
1128 if (!branchUpdate) {1128 if (!branchUpdate) {
...@@ -1131,7 +1131,7 @@ export default function GitActionsControl({...@@ -1131,7 +1131,7 @@ export default function GitActionsControl({
11311131
1132 persistThreadBranchSync(branchUpdate.branch);1132 persistThreadBranchSync(branchUpdate.branch);
1133 }, [1133 }, [
1134 activeServerThread?.branch,1134 activeServerThread,
1135 activeDraftThread?.branch,1135 activeDraftThread?.branch,
1136 gitStatusForActions,1136 gitStatusForActions,
1137 isGitActionRunning,1137 isGitActionRunning,
apps/web/src/components/Sidebar.tsx+9-2
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15import {15import {
16 ChangeRequestStatusIcon,16 ChangeRequestStatusIcon,
17 prStatusIndicator,17 prStatusIndicator,
18 PrStatusTooltipContent,
18 resolveThreadPr,19 resolveThreadPr,
19 terminalStatusFromRunningIds,20 terminalStatusFromRunningIds,
20 ThreadStatusLabel,21 ThreadStatusLabel,
...@@ -460,7 +461,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -460,7 +461,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
460 lastVisitedAt,461 lastVisitedAt,
461 },462 },
462 });463 });
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 });
464 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);469 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
465 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);470 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
466 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;471 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
...@@ -700,7 +705,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -700,7 +705,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
700 </button>705 </button>
701 }706 }
702 />707 />
703 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>708 <TooltipPopup side="top">
709 <PrStatusTooltipContent status={prStatus} />
710 </TooltipPopup>
704 </Tooltip>711 </Tooltip>
705 )}712 )}
706 {threadStatus && <ThreadStatusLabel status={threadStatus} />}713 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
apps/web/src/components/SidebarV2.tsx+27-2
...@@ -83,6 +83,7 @@ import {...@@ -83,6 +83,7 @@ import {
83 resolveSidebarV2Status,83 resolveSidebarV2Status,
84 sortThreadsForSidebarV2,84 sortThreadsForSidebarV2,
85} from "./Sidebar.logic";85} from "./Sidebar.logic";
86import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
86import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";87import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
87import { ProjectFavicon } from "./ProjectFavicon";88import { ProjectFavicon } from "./ProjectFavicon";
88import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";89import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
...@@ -127,6 +128,7 @@ function SidebarV2ThreadTooltip({...@@ -127,6 +128,7 @@ function SidebarV2ThreadTooltip({
127 modelInstanceId,128 modelInstanceId,
128 modelLabel,129 modelLabel,
129 status,130 status,
131 branchMismatch,
130}: {132}: {
131 thread: SidebarThreadSummary;133 thread: SidebarThreadSummary;
132 projectTitle: string | null;134 projectTitle: string | null;
...@@ -140,6 +142,10 @@ function SidebarV2ThreadTooltip({...@@ -140,6 +142,10 @@ function SidebarV2ThreadTooltip({
140 className: string;142 className: string;
141 icon: "working" | "done" | null;143 icon: "working" | "done" | null;
142 } | null;144 } | null;
145 branchMismatch: {
146 threadBranch: string;
147 currentBranch: string;
148 } | null;
143}) {149}) {
144 return (150 return (
145 <TooltipPopup151 <TooltipPopup
...@@ -200,6 +206,14 @@ function SidebarV2ThreadTooltip({...@@ -200,6 +206,14 @@ function SidebarV2ThreadTooltip({
200 </div>206 </div>
201 </div>207 </div>
202 ) : null}208 ) : 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}
203 {driverKind ? (217 {driverKind ? (
204 <div className="flex min-w-0 items-start gap-2">218 <div className="flex min-w-0 items-start gap-2">
205 <ProviderInstanceIcon219 <ProviderInstanceIcon
...@@ -321,14 +335,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -321,14 +335,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
321335
322 const gitCwd = thread.worktreePath ?? props.projectCwd;336 const gitCwd = thread.worktreePath ?? props.projectCwd;
323 const gitStatus = useEnvironmentQuery(337 const gitStatus = useEnvironmentQuery(
324 thread.branch != null && gitCwd !== null338 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
325 ? vcsEnvironment.status({339 ? vcsEnvironment.status({
326 environmentId: thread.environmentId,340 environmentId: thread.environmentId,
327 input: { cwd: gitCwd },341 input: { cwd: gitCwd },
328 })342 })
329 : null,343 : null,
330 );344 );
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 });
332 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);356 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
333 // Report the PR state up: the parent partitions rows with effectiveSettled,357 // Report the PR state up: the parent partitions rows with effectiveSettled,
334 // and a merged/closed PR auto-settles a thread — data only rows have.358 // and a merged/closed PR auto-settles a thread — data only rows have.
...@@ -360,6 +384,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -360,6 +384,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
360 modelInstanceId={modelInstanceId}384 modelInstanceId={modelInstanceId}
361 modelLabel={modelLabel}385 modelLabel={modelLabel}
362 status={topStatus}386 status={topStatus}
387 branchMismatch={branchMismatch}
363 />388 />
364 );389 );
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 {...@@ -22,6 +22,8 @@ export interface PrStatusIndicator {
22 label: string;22 label: string;
23 colorClass: string;23 colorClass: string;
24 tooltip: string;24 tooltip: string;
25 tooltipLead: string;
26 tooltipTitle: string;
25 url: string;27 url: string;
26}28}
2729
...@@ -37,14 +39,26 @@ export function prStatusIndicator(...@@ -37,14 +39,26 @@ export function prStatusIndicator(
37 pr: ThreadPr,39 pr: ThreadPr,
38 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,40 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
39): PrStatusIndicator | null {41): 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 }
40 if (!pr) return null;49 if (!pr) return null;
41 const presentation = resolveChangeRequestPresentation(provider);50 const presentation = resolveChangeRequestPresentation(provider);
4251
52 const tooltipLead = formatPrStatusLead(pr, presentation.shortName);
53 const tooltip = `${tooltipLead}: ${pr.title}`;
54
43 if (pr.state === "open") {55 if (pr.state === "open") {
44 return {56 return {
45 label: `${presentation.shortName} open`,57 label: `${presentation.shortName} open`,
46 colorClass: "text-emerald-600 dark:text-emerald-300/90",58 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,
48 url: pr.url,62 url: pr.url,
49 };63 };
50 }64 }
...@@ -52,7 +66,9 @@ export function prStatusIndicator(...@@ -52,7 +66,9 @@ export function prStatusIndicator(
52 return {66 return {
53 label: `${presentation.shortName} closed`,67 label: `${presentation.shortName} closed`,
54 colorClass: "text-zinc-500 dark:text-zinc-400/80",68 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,
56 url: pr.url,72 url: pr.url,
57 };73 };
58 }74 }
...@@ -60,7 +76,9 @@ export function prStatusIndicator(...@@ -60,7 +76,9 @@ export function prStatusIndicator(
60 return {76 return {
61 label: `${presentation.shortName} merged`,77 label: `${presentation.shortName} merged`,
62 colorClass: "text-violet-600 dark:text-violet-300/90",78 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,
64 url: pr.url,82 url: pr.url,
65 };83 };
66 }84 }
...@@ -71,11 +89,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) {...@@ -71,11 +89,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) {
71 return <GitPullRequestIcon className={className} />;89 return <GitPullRequestIcon className={className} />;
72}90}
7391
74export function resolveThreadPr(92export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) {
75 threadBranch: string | null,93 return (
76 gitStatus: VcsStatusResult | null,94 <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap">
77): ThreadPr | null {95 <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span>
78 if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) {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) {
79 return null;117 return null;
80 }118 }
81119
...@@ -199,14 +237,18 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -199,14 +237,18 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
199 const threadProjectCwd = threadProject?.workspaceRoot ?? null;237 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
200 const gitCwd = thread.worktreePath ?? threadProjectCwd;238 const gitCwd = thread.worktreePath ?? threadProjectCwd;
201 const gitStatus = useEnvironmentQuery(239 const gitStatus = useEnvironmentQuery(
202 thread.branch != null && gitCwd !== null240 (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
203 ? vcsEnvironment.status({241 ? vcsEnvironment.status({
204 environmentId: thread.environmentId,242 environmentId: thread.environmentId,
205 input: { cwd: gitCwd },243 input: { cwd: gitCwd },
206 })244 })
207 : null,245 : null,
208 );246 );
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 });
210 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);252 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
211 const threadStatus = resolveThreadStatusPill({253 const threadStatus = resolveThreadStatusPill({
212 thread: {254 thread: {
...@@ -233,7 +275,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -233,7 +275,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
233 >275 >
234 <ChangeRequestStatusIcon className="size-3" />276 <ChangeRequestStatusIcon className="size-3" />
235 </TooltipTrigger>277 </TooltipTrigger>
236 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>278 <TooltipPopup side="top">
279 <PrStatusTooltipContent status={prStatus} />
280 </TooltipPopup>
237 </Tooltip>281 </Tooltip>
238 ) : null}282 ) : null}
239 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}283 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
apps/web/src/components/chat/ComposerBannerStack.test.tsx+18
...@@ -34,4 +34,22 @@ describe("ComposerBannerStack", () => {...@@ -34,4 +34,22 @@ describe("ComposerBannerStack", () => {
3434
35 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");35 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
36 });36 });
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 });
37});55});
apps/web/src/components/chat/ComposerBannerStack.tsx+7-4
...@@ -30,6 +30,8 @@ export interface ComposerBannerStackItem {...@@ -30,6 +30,8 @@ export interface ComposerBannerStackItem {
30 readonly title: ReactNode;30 readonly title: ReactNode;
31 readonly description?: ReactNode;31 readonly description?: ReactNode;
32 readonly actions?: ReactNode;32 readonly actions?: ReactNode;
33 readonly className?: string;
34 readonly actionClassName?: string;
33 readonly dismissLabel?: string;35 readonly dismissLabel?: string;
34 readonly onDismiss?: () => void;36 readonly onDismiss?: () => void;
35}37}
...@@ -171,17 +173,18 @@ function ComposerBannerStackAlert({...@@ -171,17 +173,18 @@ function ComposerBannerStackAlert({
171 const dismissOnly = item.onDismiss && !item.actions;173 const dismissOnly = item.onDismiss && !item.actions;
172174
173 return (175 return (
174 <Alert variant={item.variant}>176 <Alert variant={item.variant} className={item.className}>
175 {item.icon}177 {item.icon}
176 <AlertTitle>{item.title}</AlertTitle>178 <AlertTitle>{item.title}</AlertTitle>
177 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}179 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}
178 {item.actions || item.onDismiss ? (180 {item.actions || item.onDismiss ? (
179 <AlertAction181 <AlertAction
180 className={182 className={cn(
183 item.actionClassName,
181 dismissOnly184 dismissOnly
182 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"185 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"
183 : undefined186 : undefined,
184 }187 )}
185 >188 >
186 {item.actions}189 {item.actions}
187 {item.onDismiss ? (190 {item.onDismiss ? (