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