diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 530e0488cf..e070500abd 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -8,6 +8,7 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -32,7 +33,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import { makeGitManager } from "./GitManager.ts"; +import { makeGitManager, matchesBranchHeadContext } from "./GitManager.ts"; import { ServerConfig } from "../config.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { @@ -2197,6 +2198,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect( + "does not reuse a cross-repo PR when GitHub omits head identity metadata", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, + `[{"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"}}]`, + ], + "fork-seed:statemachine": ["[]"], + statemachine: ["[]"], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(142); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); + }), + 12_000, + ); + + it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => + Effect.sync(() => { + const headContext = { + headBranch: "statemachine", + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + isCrossRepository: true, + }; + + expect( + matchesBranchHeadContext( + { + number: 41, + title: "Same-repo PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(false); + + expect( + matchesBranchHeadContext( + { + number: 142, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + headContext, + ), + ).toBe(true); + }), + ); + + it.effect("accepts fork PR metadata when origin is the fork checkout remote", () => + Effect.sync(() => { + const headContext = { + headBranch: "t3code/git-audit-stability", + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + isCrossRepository: false, + }; + + expect( + matchesBranchHeadContext( + { + number: 2284, + title: "Improve branch mismatch warnings", + url: "https://github.com/pingdotgg/t3code/pull/2284", + baseRefName: "main", + headRefName: "t3code/git-audit-stability", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "justsomelegs/t3code", + headRepositoryOwnerLogin: "justsomelegs", + }, + headContext, + ), + ).toBe(true); + }), + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8dfb957b89..f9a5324c99 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -248,7 +248,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } -function matchesBranchHeadContext( +interface PullRequestHeadIdentity { + readonly repositoryNameWithOwner: string | null; + readonly ownerLogin: string | null; +} + +function resolveExpectedHeadIdentity( + headContext: Pick, +): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { + const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + return { + repositoryNameWithOwner, + ownerLogin: + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; +} + +export function matchesBranchHeadContext( pr: PullRequestInfo, headContext: Pick< BranchHeadContext, @@ -259,44 +290,51 @@ function matchesBranchHeadContext( return false; } - const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( - headContext.headRepositoryNameWithOwner, - ); - const expectedHeadOwner = - normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(expectedHeadRepository); - const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), - ); - const prHeadOwner = - normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(prHeadRepository); + const expectedHead = resolveExpectedHeadIdentity(headContext); + const pullRequestHead = resolvePullRequestHeadIdentity(pr); - if (headContext.isCrossRepository) { - if (pr.isCrossRepository === false) { - return false; + if (expectedHead.repositoryNameWithOwner) { + if (pullRequestHead.repositoryNameWithOwner) { + if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) { + return false; + } } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { + return false; + } + } + } + + if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) { + if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) { return false; } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + } + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { return false; } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + if ( + (expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) && + !pullRequestHead.repositoryNameWithOwner && + !pullRequestHead.ownerLogin + ) { return false; } return true; } if (pr.isCrossRepository === true) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; + if ( + (!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) || + (!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin) + ) { + return false; + } } + return true; } diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 3db5237373..ee50eea1bb 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -11,6 +11,7 @@ import { resolveEnvModeLabel, resolveBranchToolbarValue, resolveLockedWorkspaceLabel, + resolveLocalCheckoutBranchMismatch, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -84,6 +85,55 @@ describe("resolveBranchToolbarValue", () => { }); }); +describe("resolveLocalCheckoutBranchMismatch", () => { + it("detects when a local thread is associated with a different branch than the checkout", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toEqual({ + threadBranch: "feature/thread", + currentBranch: "feature/current", + }); + }); + + it("ignores matching local checkout state", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "local", + activeWorktreePath: null, + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/thread", + }), + ).toBeNull(); + }); + + it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: "/repo/.t3/worktrees/feature-thread", + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", + }), + ).toBeNull(); + }); + + it("ignores new-worktree base selection before a worktree exists", () => { + expect( + resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: "worktree", + activeWorktreePath: null, + activeThreadBranch: "feature/base", + currentGitBranch: "main", + }), + ).toBeNull(); + }); +}); + describe("resolveEnvironmentOptionLabel", () => { it("prefers the primary environment's machine label", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c0..145d3d95c3 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -97,6 +97,22 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } +export function resolveLocalCheckoutBranchMismatch(input: { + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + activeThreadBranch: string | null; + currentGitBranch: string | null; +}): { threadBranch: string; currentBranch: string } | null { + const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + if (effectiveEnvMode !== "local" || activeWorktreePath !== null) { + return null; + } + if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) { + return null; + } + return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; +} + export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2e30edfc02..1622e33d76 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -2,7 +2,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useDeferredValue, @@ -17,9 +17,8 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { readEnvironmentApi } from "../environmentApi"; import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; -import { useGitStatus } from "../lib/gitStatusState"; -import { newCommandId } from "../lib/utils"; -import { cn } from "../lib/utils"; +import { refreshGitStatus, useGitStatus } from "../lib/gitStatusState"; +import { cn, newCommandId } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useStore } from "../store"; @@ -30,6 +29,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; @@ -44,6 +44,7 @@ import { ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { stackedThreadToast, toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps { @@ -198,7 +199,9 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [isMismatchPopoverOpen, setIsMismatchPopoverOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const branchPickerAnchorRef = useRef(null); const deferredBranchQuery = useDeferredValue(branchQuery); const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); @@ -242,6 +245,12 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, currentGitBranch, }); + const localCheckoutBranchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch, + }); const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( () => new Map(refs.map((refName) => [refName.name, refName] as const)), @@ -311,6 +320,7 @@ export function BranchToolbarBranchSelector({ await queryClient .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) .catch(() => undefined); + await refreshGitStatus({ environmentId, cwd: branchCwd }).catch(() => undefined); }); }; @@ -403,6 +413,48 @@ export function BranchToolbarBranchSelector({ }); }; + const switchCheckoutToThreadBranch = () => { + const api = readEnvironmentApi(environmentId); + if (!api || !activeProjectCwd || !localCheckoutBranchMismatch || isBranchActionPending) { + return; + } + + runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; + setOptimisticBranch(localCheckoutBranchMismatch.threadBranch); + try { + const checkoutResult = await api.vcs.switchRef({ + cwd: activeProjectCwd, + refName: localCheckoutBranchMismatch.threadBranch, + }); + const nextBranchName = checkoutResult.refName ?? localCheckoutBranchMismatch.threadBranch; + setOptimisticBranch(nextBranchName); + setThreadBranch(nextBranchName, null); + setIsMismatchPopoverOpen(false); + onComposerFocusRequest?.(); + } catch (error) { + setOptimisticBranch(previousBranch); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch checkout.", + description: toBranchActionErrorMessage(error), + }), + ); + } + }); + }; + + const useCurrentCheckoutForThread = () => { + if (!localCheckoutBranchMismatch || isBranchActionPending) { + return; + } + + setThreadBranch(localCheckoutBranchMismatch.currentBranch, null); + setIsMismatchPopoverOpen(false); + onComposerFocusRequest?.(); + }; + useEffect(() => { if ( effectiveEnvMode !== "worktree" || @@ -571,72 +623,146 @@ export function BranchToolbarBranchSelector({ } return ( - { - if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { - return; - } - branchListRef.current?.scrollIndexIntoView?.({ - index: eventDetails.index, - animated: false, - }); - }} - onOpenChange={handleOpenChange} - open={isBranchMenuOpen} - value={resolvedActiveBranch} - > - } - className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} +
+ {localCheckoutBranchMismatch ? ( + + + } + > + + +
+
+ Thread + + {localCheckoutBranchMismatch.threadBranch} + + Checkout + + {localCheckoutBranchMismatch.currentBranch} + +
+

+ This thread was last associated with{" "} + {localCheckoutBranchMismatch.threadBranch}, but your checkout is on + another branch. Switch to keep working there, or use current to update the thread to + this checkout. +

+
+ + +
+
+
+
+ ) : null} + { + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); + }} + onOpenChange={handleOpenChange} + open={isBranchMenuOpen} + value={resolvedActiveBranch} > - {triggerLabel} - - - -
- setBranchQuery(event.target.value)} - /> -
- No refs found. - - {shouldVirtualizeBranchList ? ( - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - void fetchNextPage().catch(() => undefined); - } - }} - style={{ maxHeight: "14rem" }} + } + className={cn( + "min-w-0 text-muted-foreground/70 hover:text-foreground/80", + localCheckoutBranchMismatch && + "border-warning/35 bg-warning/10 pl-7 text-warning hover:bg-warning/15 hover:text-warning", + className, + )} + disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} + > + {triggerLabel} + + + +
+ setBranchQuery(event.target.value)} /> - - ) : ( - - {filteredBranchPickerItems.map((itemValue, index) => - renderPickerItem(itemValue, index), - )} - - )} - {branchStatusText ? {branchStatusText} : null} - - +
+ No refs found. + + {shouldVirtualizeBranchList ? ( + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => undefined); + } + }} + style={{ maxHeight: "14rem" }} + /> + + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + + )} + {branchStatusText ? {branchStatusText} : null} +
+
+
); } diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a2a801d54a..36ffee8612 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -29,6 +29,7 @@ const { invalidateGitQueriesSpy, refreshGitStatusSpy, runStackedActionMutateAsyncSpy, + serverThreadBranchRef, setDraftThreadContextSpy, setThreadBranchSpy, toastAddSpy, @@ -42,6 +43,7 @@ const { invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), + serverThreadBranchRef: { current: "feature/toast-scope" }, setDraftThreadContextSpy: vi.fn(), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -209,7 +211,7 @@ vi.mock("~/store", () => ({ ? { [SHARED_THREAD_ID]: { id: SHARED_THREAD_ID, - branch: BRANCH_NAME, + branch: serverThreadBranchRef.current, worktreePath: null, }, } @@ -230,7 +232,7 @@ vi.mock("~/store", () => ({ ? { [SHARED_THREAD_ID]: { id: SHARED_THREAD_ID, - branch: BRANCH_NAME, + branch: serverThreadBranchRef.current, worktreePath: null, }, } @@ -287,6 +289,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { activeRunStackedActionDeferredRef.current = createDeferredPromise(); activeDraftThreadRef.current = null; hasServerThreadRef.current = true; + serverThreadBranchRef.current = BRANCH_NAME; document.body.innerHTML = ""; }); @@ -438,6 +441,32 @@ describe("GitActionsControl thread-scoped progress toast", () => { } }); + it("does not overwrite an existing server thread branch when the shared checkout differs", async () => { + serverThreadBranchRef.current = "feature/thread-branch"; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { + container: host, + }, + ); + + try { + await Promise.resolve(); + + expect(setThreadBranchSpy).not.toHaveBeenCalled(); + expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + host.remove(); + } + }); + it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { hasServerThreadRef.current = false; activeDraftThreadRef.current = { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d8c5cdd3fa..1d8587d056 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1106,12 +1106,12 @@ export default function GitActionsControl({ activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase) { + if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, + threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { @@ -1120,7 +1120,7 @@ export default function GitActionsControl({ persistThreadBranchSync(branchUpdate.branch); }, [ - activeServerThread?.branch, + activeServerThread, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3d0ccd3ab7..e8470313d6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ import { import { ChangeRequestStatusIcon, prStatusIndicator, + PrStatusTooltipContent, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, @@ -371,7 +372,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useGitStatus({ environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, + cwd: thread.branch != null || thread.worktreePath !== null ? gitCwd : null, }); const isHighlighted = isActive || isSelected; const isThreadRunning = @@ -382,7 +383,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP lastVisitedAt, }, }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -572,7 +577,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } /> - {prStatus.tooltip} + + + )} {threadStatus && } diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts new file mode 100644 index 0000000000..6e358a3926 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -0,0 +1,73 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/current", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 42, + title: "PR branch", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "open", + }, + ...overrides, + }; +} + +describe("resolveThreadPr", () => { + it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { + expect( + resolveThreadPr({ + threadBranch: "feature/other", + gitStatus: status(), + hasDedicatedWorktree: false, + }), + ).toBeNull(); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is stale", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: "feature/old-name", + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); + + it("shows PR indicators for dedicated worktree threads even when branch metadata is missing", () => { + const gitStatus = status(); + + expect( + resolveThreadPr({ + threadBranch: null, + gitStatus, + hasDedicatedWorktree: true, + }), + ).toBe(gitStatus.pr); + }); +}); + +describe("prStatusIndicator", () => { + it("formats PR tooltips with number, uppercase status, and title", () => { + expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ + tooltip: "PR #42 - Open: PR branch", + tooltipLead: "PR #42 - Open", + tooltipTitle: "PR branch", + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 5ed0c0e4c4..9c594ff055 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -20,6 +20,8 @@ export interface PrStatusIndicator { label: string; colorClass: string; tooltip: string; + tooltipLead: string; + tooltipTitle: string; url: string; } @@ -35,14 +37,26 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { + function formatPrState(state: NonNullable["state"]): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } + + function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); + const tooltipLead = formatPrStatusLead(pr, presentation.shortName); + const tooltip = `${tooltipLead}: ${pr.title}`; + if (pr.state === "open") { return { label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -50,7 +64,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -58,7 +74,9 @@ export function prStatusIndicator( return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, + tooltip, + tooltipLead, + tooltipTitle: pr.title, url: pr.url, }; } @@ -69,11 +87,31 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) { return ; } -export function resolveThreadPr( - threadBranch: string | null, - gitStatus: VcsStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { +export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { + return ( + + {status.tooltipLead} + + ); +} + +export function resolveThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + hasDedicatedWorktree: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, hasDedicatedWorktree } = input; + if (gitStatus === null) { + return null; + } + + if (hasDedicatedWorktree) { + return gitStatus.pr ?? null; + } + + if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } @@ -152,9 +190,13 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const gitCwd = thread.worktreePath ?? threadProjectCwd; const gitStatus = useGitStatus({ environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, + cwd: thread.branch != null || thread.worktreePath !== null ? gitCwd : null, + }); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { @@ -181,7 +223,9 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar > - {prStatus.tooltip} + + + ) : null} {threadStatus ? : null}