| 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,6 +8,7 @@ import { it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; | ||
| 11 | import * as PlatformError from "effect/PlatformError"; | 12 | import * as PlatformError from "effect/PlatformError"; |
| 12 | import * as Scope from "effect/Scope"; | 13 | import * as Scope from "effect/Scope"; |
| 13 | import { ChildProcessSpawner } from "effect/unstable/process"; | 14 | import { 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 | ); |
| 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 | 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 | } |
| 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 | 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 | } |
| 265 | 296 | ||
| 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); | ||
| 278 | 299 | ||
| 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 | } |
| 294 | 332 | ||
| 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 | } |
| 306 | 344 |
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 | }); |
| 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 | describe("resolveEnvironmentOptionLabel", () => { | 138 | describe("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 | } |
| 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 | export function resolveBranchSelectionTarget(input: { | 127 | export 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 = isInitialBranchesLoadPending | 473 | const worktreeBaseBranchCandidate = isInitialBranchesLoadPending |
| 474 | ? null | 474 | ? 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 | }); |
| 590 | 591 | ||
| 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's | 599 | // 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 | }; |
| 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 | describe("buildThreadTurnInterruptInput", () => { | 111 | describe("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; |
| 27 | 27 | ||
| 28 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); | 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 | export function buildLocalDraftThread( | 57 | export 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"; |
| 212 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 212 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 213 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 213 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 214 | import { NoActiveThreadState } from "./NoActiveThreadState"; | 214 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 215 | import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; | 215 | import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 216 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | 216 | import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 217 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 217 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 218 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 218 | import { 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 | }; |
| 1082 | 1083 | ||
| 1084 | function chatActionErrorMessage(error: unknown): string { | ||
| 1085 | return error instanceof Error ? error.message : "An error occurred."; | ||
| 1086 | } | ||
| 1087 | |||
| 1083 | function ChatViewContent(props: ChatViewProps) { | 1088 | function 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 | } |
| 3259 | 3266 | ||
| 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 | ]); | ||
| 3767 | 3947 | ||
| 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; |
| 1118 | 1118 | ||
| 1119 | useEffect(() => { | 1119 | useEffect(() => { |
| 1120 | if (isGitActionRunning || isSelectingWorktreeBase) { | 1120 | if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { |
| 1121 | return; | 1121 | return; |
| 1122 | } | 1122 | } |
| 1123 | 1123 | ||
| 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({ |
| 1131 | 1131 | ||
| 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 { |
| 15 | import { | 15 | import { |
| 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"; |
| 86 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; | ||
| 86 | import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; | 87 | import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; |
| 87 | import { ProjectFavicon } from "./ProjectFavicon"; | 88 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 88 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | 89 | import { 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 | <TooltipPopup | 151 | <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 | <ProviderInstanceIcon | 219 | <ProviderInstanceIcon |
| ... | @@ -321,14 +335,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { | ... | @@ -321,14 +335,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 321 | 335 | ||
| 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 !== null | 338 | (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 | ); |
| 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,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 | } |
| 27 | 29 | ||
| ... | @@ -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); |
| 42 | 51 | ||
| 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 | } |
| 73 | 91 | ||
| 74 | export function resolveThreadPr( | 92 | export 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 | |||
| 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 | return null; | 117 | return null; |
| 80 | } | 118 | } |
| 81 | 119 | ||
| ... | @@ -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 !== null | 240 | (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", () => { |
| 34 | 34 | ||
| 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; |
| 172 | 174 | ||
| 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 | <AlertAction | 181 | <AlertAction |
| 180 | className={ | 182 | className={cn( |
| 183 | item.actionClassName, | ||
| 181 | dismissOnly | 184 | 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 | : undefined | 186 | : undefined, |
| 184 | } | 187 | )} |
| 185 | > | 188 | > |
| 186 | {item.actions} | 189 | {item.actions} |
| 187 | {item.onDismiss ? ( | 190 | {item.onDismiss ? ( |