diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 34b24ba845db..9e51b059e023 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -29,7 +29,7 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { useOpenPrLink, useOpenThreadPullRequestTabs } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThreadShell } from "../state/entities"; @@ -689,6 +689,7 @@ export function BranchToolbarBranchSelector({ const prNumber = currentLinkedPr?.number ?? displayedPr?.number; const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); + const openThreadPullRequestTabs = useOpenThreadPullRequestTabs(threadRef); function selectPickerItem(itemValue: string) { highlightedBranchValueRef.current = null; @@ -801,10 +802,17 @@ export function BranchToolbarBranchSelector({ useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenList={() => { + // Unrelated PRs (one per repo, say) open a tab each; a stack opens its ordered list. + const openedTabs = + prBadge?.kind === "pull-request" && + openThreadPullRequestTabs(serverThread?.pullRequests ?? [], prUrl); + if (!openedTabs) useRightPanelStore.getState().open(threadRef, "pull-requests"); + }} onOpenPullRequest={(event) => { if (prUrl) openPrLink(event, prUrl); }} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c488652d4e5f..0ec0698c8e4b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,6 +76,7 @@ import { resolveProjectScripts, } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { threadWorkspaceFilePath } from "@t3tools/shared/path"; import { truncate } from "@t3tools/shared/String"; @@ -118,7 +119,7 @@ import { import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useVcsStatusGroups } from "~/lib/vcsStatusState"; +import { repoRootDisplayName, useVcsStatusGroups } from "~/lib/vcsStatusState"; import { isElectron } from "../env"; import { repoBaseBranchesForSend } from "../repoBaseBranchStore"; import { readLocalApi } from "../localApi"; @@ -3665,14 +3666,28 @@ export default function ChatView(props: ChatViewProps) { ? threadWorkspaceFilePath({ anchorWorktreePath, projectWorkspaceFile }) : null; // For a multi-repo `.code-workspace` project, fan git status out over every - // repo root. For a single-repo project keep the worktree-aware status cwd so - // isolated runs report on the worktree (Phase 4 will make multi-repo - // worktree-aware too). + // repo root, swapping in the thread's worktree for roots an isolated run + // covers. A single-repo project uses the worktree-aware status cwd. const isMultiRepo = (activeProject?.repoRoots?.length ?? 0) > 1; const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; + const threadWorktrees = activeThread?.worktrees; + const multiRepoStatusRoots = useMemo(() => { + if (!isMultiRepo || !activeProject?.repoRoots) return null; + return activeProject.repoRoots.map((repoRoot) => ({ + repoRoot, + cwd: + threadWorktrees?.find((worktree) => worktree.repoRoot === repoRoot)?.worktreePath ?? + repoRoot, + })); + }, [isMultiRepo, activeProject?.repoRoots, threadWorktrees]); const gitStatusRoots = useMemo( - () => (isMultiRepo ? (activeProject?.repoRoots ?? null) : gitStatusCwd ? [gitStatusCwd] : null), - [isMultiRepo, activeProject?.repoRoots, gitStatusCwd], + () => + multiRepoStatusRoots + ? multiRepoStatusRoots.map((root) => root.cwd) + : gitStatusCwd + ? [gitStatusCwd] + : null, + [multiRepoStatusRoots, gitStatusCwd], ); // @-mention file search spans every repo root for a multi-repo workspace // (#923); single-repo projects search the worktree-aware `gitCwd` alone. @@ -3705,12 +3720,19 @@ export default function ChatView(props: ChatViewProps) { ); const repoStatusGroups = useMemo( () => - gitRepoGroups.map((group) => ({ - repoRoot: group.repoRoot, - displayName: group.displayName, - state: group.state, - })), - [gitRepoGroups], + gitRepoGroups.map((group) => { + // A worktree's basename is its branch slug; label the row by its repo. + const sourceRoot = multiRepoStatusRoots?.find((root) => root.cwd === group.repoRoot); + return { + repoRoot: group.repoRoot, + displayName: + sourceRoot && sourceRoot.cwd !== sourceRoot.repoRoot + ? repoRootDisplayName(sourceRoot.repoRoot) + : group.displayName, + state: group.state, + }; + }), + [gitRepoGroups, multiRepoStatusRoots], ); // Per-root targets for the terminal surface picker; undefined for single-repo // projects so the Terminal action opens directly (no dropdown). @@ -4761,19 +4783,19 @@ export default function ChatView(props: ChatViewProps) { readonly reference: ThreadLinkedPullRequest | null; } | null>(null); const openProjectPullRequest = useCallback( - (number: number) => { - if ( - !supportsPullRequests || - !activeThreadRef || - !activeProject || - activeProjectRepository === null - ) { - return; - } + ({ number, url }: { readonly number: number; readonly url: string }) => { + if (!supportsPullRequests || !activeThreadRef || !activeProject) return; + // A multi-repo row's PR can live in any of the project's repos; its URL + // names the repo. The primary repo covers URLs this cannot read. + const parsed = parseChangeRequestUrl(url); + const repository = parsed?.repository ?? activeProjectRepository; + if (repository === null) return; useRightPanelStore.getState().openPullRequest(activeThreadRef, { projectId: activeProject.id, - repository: activeProjectRepository, + ...(parsed ? { host: parsed.host } : {}), + repository, number, + url, }); }, [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 3264ef76e5b0..441578ab99da 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -125,7 +125,9 @@ interface GitActionsControlProps { * Opens the thread's own change request beside it. Absent when the thread has no project to * place it against, in which case it still opens in the browser. */ - onOpenPullRequest?: ((number: number) => void) | undefined; + onOpenPullRequest?: + | ((pullRequest: { readonly number: number; readonly url: string }) => void) + | undefined; /** * Whether this control should keep the thread's single `branch` field in sync * with its repo's live branch. Defaults to true. Disabled for the per-repo @@ -1234,7 +1236,7 @@ export default function GitActionsControl({ // Beside the thread where it was made, the way the browser opens beside it. Checked before // the shell, which opening in the app does not need. if (openPr && onOpenPullRequest) { - onOpenPullRequest(openPr.number); + onOpenPullRequest(openPr); return; } const prUrl = openPr?.url ?? null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c46dae3b019c..0afa32401c39 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -101,7 +101,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { useOpenPrLink, useOpenThreadPullRequestTabs } from "../lib/openPullRequestLink"; import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { @@ -201,6 +201,7 @@ import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadPullRequestBadgeControl, + threadPullRequestBadgeOpensList, ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, @@ -1504,24 +1505,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); - // Stacks show their layer count; multiple unrelated links show their total count. - // Plain clicks open T3; individual PR links also support opening the host in a new tab. + // Stacks show their layer count; multiple unrelated links show their total count. A single PR + // link also supports opening the host in a new tab. const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) : null; - const handlePrStackClick = useCallback(() => { - useRightPanelStore.getState().open(threadRef, "pull-requests"); + const openThreadPullRequestTabs = useOpenThreadPullRequestTabs(threadRef); + const handlePrListClick = useCallback(() => { + // Unrelated PRs (one per repo, say) open a tab each; a stack reads best as its ordered list. + const openedTabs = + prBadgeShape?.kind === "pull-request" && + openThreadPullRequestTabs(thread.pullRequests, currentLinkedPr?.url); + if (!openedTabs) useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); - }, [onThreadActivate, props.isActive, threadRef]); + }, [ + currentLinkedPr?.url, + onThreadActivate, + openThreadPullRequestTabs, + prBadgeShape?.kind, + props.isActive, + thread.pullRequests, + threadRef, + ]); const prBadge = - prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( + threadPullRequestBadgeOpensList(prBadgeShape) || pr || currentLinkedPr ? ( ) : null; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 60b17b19aa6b..0bf3b6a5fdea 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -5,6 +5,7 @@ import { ChangeRequestStatusIcon, prStatusIndicator, resolveThreadPullRequestBadgePresentation, + threadPullRequestBadgeOpensList, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -117,6 +118,16 @@ describe("prStatusIndicator", () => { describe("resolveThreadPullRequestBadgePresentation", () => { const url = "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/pingdotgg/t3code/pull/42"; + it("summarizes unrelated links as a count instead of naming one of them", () => { + const badge = { kind: "pull-request", others: 1, state: "open" } as const; + expect(resolveThreadPullRequestBadgePresentation({ badge, status: null })).toMatchObject({ + label: "2 linked pull requests, overall open", + text: "+2", + }); + expect(threadPullRequestBadgeOpensList(badge)).toBe(true); + expect(threadPullRequestBadgeOpensList({ ...badge, others: 0 })).toBe(false); + }); + it("returns the pending pull-request badge when no snapshot is available", () => { expect( resolveThreadPullRequestBadgePresentation({ @@ -232,7 +243,7 @@ describe("resolveThreadPullRequestBadgePresentation", () => { ).toEqual({ Icon: expectedIcon, toneClassName: expectedToneClassName, - label: `PR #42 - Closed: PR branch, and 2 more linked; overall ${state}`, + label: `3 linked pull requests, overall ${state}`, text: "+3", }); }, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8b27b74fd0a5..3631d177da93 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -170,19 +170,19 @@ export function resolveThreadPullRequestBadgePresentation({ text: badge.layers, }; } - if (number === undefined || url === undefined) return null; - - const tooltip = status?.tooltip ?? `PR #${number}, status pending`; if (badge?.kind === "pull-request" && badge.others > 0) { // Unrelated links fold into one state, so a count of merged PRs reads as merged. const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state]; return { Icon: aggregate.Icon, toneClassName: aggregate.toneClassName, - label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`, + label: `${badge.others + 1} linked pull requests, overall ${aggregate.label.toLowerCase()}`, text: `+${badge.others + 1}`, }; } + if (number === undefined || url === undefined) return null; + + const tooltip = status?.tooltip ?? `PR #${number}, status pending`; return { Icon: status?.Icon ?? PullRequestGlyph.pullRequest, toneClassName: status?.colorClass ?? "text-muted-foreground", @@ -191,43 +191,54 @@ export function resolveThreadPullRequestBadgePresentation({ }; } -/** The complete linked-PR control shared by the sidebar and composer footer. */ +/** Whether the badge stands for several pull requests, so it opens the list rather than one. */ +export function threadPullRequestBadgeOpensList(badge: ThreadPullRequestBadge | null): boolean { + return badge?.kind === "stack" || (badge?.kind === "pull-request" && badge.others > 0); +} + +/** + * The complete linked-PR control shared by the sidebar and composer footer. A badge for several + * pull requests opens the pull-requests panel and lists them all on hover; a single one opens + * that pull request. + */ export function ThreadPullRequestBadgeControl({ variant, badge, + pullRequests, number, url, status, - onOpenStack, + onOpenList, onOpenPullRequest, }: { variant: "underline" | "ghost"; badge: ThreadPullRequestBadge | null; + pullRequests?: ReadonlyArray | undefined; number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; - onOpenStack: () => void; + onOpenList: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); if (presentation === null) return null; - const isStack = badge?.kind === "stack"; + const opensList = threadPullRequestBadgeOpensList(badge); const content = ( <> {presentation.text} ); - const linkProps = isStack + const linkProps = opensList ? { onClick: (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - onOpenStack(); + onOpenList(); }, } : { onClick: onOpenPullRequest }; - const element = isStack ? ( + const element = opensList ? (