From ee34f5da6dc48fe365d6e3acf737e90e3a2ae8ad Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 24 Sep 2026 15:15:42 +1000 Subject: [PATCH] fix(web): reach every PR of a multi-repo thread The +N PR badge in the sidebar and composer now opens a right-panel tab per pull request when they are unrelated (one per repo, say), and lists them all on hover. Stacks still open the ordered Pull requests list. The multi-repo Source Control popover reads each repo's worktree for an isolated run instead of the original checkout, so each row shows the run's PR. View PR passes the PR's URL, so a non-primary repo row opens its own repository's PR rather than the same number in the primary repo. Fixes #298 --- .../BranchToolbarBranchSelector.tsx | 12 +++- apps/web/src/components/ChatView.tsx | 66 ++++++++++++------- apps/web/src/components/GitActionsControl.tsx | 6 +- apps/web/src/components/Sidebar.tsx | 31 ++++++--- .../components/ThreadStatusIndicators.test.ts | 13 +++- .../src/components/ThreadStatusIndicators.tsx | 41 ++++++++---- apps/web/src/components/chat/ChatHeader.tsx | 4 +- .../components/chat/MultiRepoGitControl.tsx | 4 +- apps/web/src/lib/openPullRequestLink.ts | 39 ++++++++++- apps/web/src/lib/vcsStatusState.ts | 2 +- 10 files changed, 167 insertions(+), 51 deletions(-) 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 ? (