diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 93b9aaf7ac10..23b92cf873b3 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -39,7 +39,7 @@ import { THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, } from "./chat/threadDetailsPanelStyles"; -import { ThreadDetailsPrRow } from "./chat/ThreadDetailsPrRow"; +import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useComposerMenuProps } from "./chat/composerEventScope"; @@ -735,7 +735,10 @@ export function BranchToolbarBranchSelector({ {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( - ({ + ThreadDetailsPrRow: ({ number }: { number: number }) => , +})); +vi.mock("~/state/entities", () => ({ useProjects: () => [] })); +vi.mock("~/lib/openPullRequestLink", () => ({ + parseChangeRequestUrl: () => null, + findProjectOnChangeRequestHost: () => undefined, +})); + +import { ThreadDetailsPrRows } from "./ThreadDetailsPrRows"; + +function link( + number: number, + headBranch: string, + baseBranch: string, + updatedAt: string, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "manual", + linkedAt: updatedAt, + snapshot: { + state: "open", + title: `Change ${number}`, + headBranch, + baseBranch, + isDraft: false, + updatedAt, + syncedAt: updatedAt, + }, + stack: null, + }; +} + +const bottom = link(1, "layer-one", "main", "2026-01-01T00:00:10.000Z"); +const top = link(2, "layer-two", "layer-one", "2026-01-01T00:00:20.000Z"); +const other = link(3, "unrelated", "main", "2026-01-01T00:00:05.000Z"); + +let renderer: ReactTestRenderer; +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +function render(links: ReadonlyArray, current: ThreadPullRequestLink) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + act(() => { + renderer = create( + , + ); + }); +} + +const rows = () => renderer.root.findAllByType("span").map((node) => node.props["data-row"]); +const toggleLabel = () => + renderer.root + .findAllByType("button") + .at(-1) + ?.children.filter((child) => typeof child === "string") + .join(""); + +function toggle() { + act(() => { + (renderer.root.findAllByType("button").at(-1)!.props as { onClick: () => void }).onClick(); + }); +} + +it("shows only the current pull request until the rest are asked for", () => { + render([other, bottom, top], top); + expect(rows()).toEqual(["2"]); + expect(toggleLabel()).toBe("Show 2 more"); + + toggle(); + expect(rows()).toEqual(["2", "1", "3"]); + expect(toggleLabel()).toBe("Show less"); + + toggle(); + expect(rows()).toEqual(["2"]); + expect(toggleLabel()).toBe("Show 2 more"); +}); + +it("keeps the single row untouched when the thread links one pull request", () => { + render([bottom], bottom); + expect(rows()).toEqual(["1"]); + expect(toggleLabel()).toBeUndefined(); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx new file mode 100644 index 000000000000..73de367107e2 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -0,0 +1,109 @@ +import type { EnvironmentId, ThreadPullRequestLink } from "@t3tools/contracts"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { MinusIcon, PlusIcon } from "lucide-react"; +import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; + +import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { cn } from "~/lib/utils"; +import { useProjects } from "~/state/entities"; + +import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; +import { linkedPullRequestSnapshotStatus, prStatusIndicator } from "../ThreadStatusIndicators"; +import { Button } from "../ui/button"; +import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; +import { THREAD_DETAILS_PANEL_ROW_CLASS } from "./threadDetailsPanelStyles"; + +function ThreadDetailsPrLinkRow({ + environmentId, + link, + onOpen, + onActed, +}: { + environmentId: EnvironmentId; + link: ThreadPullRequestLink; + onOpen: (event: ReactMouseEvent) => void; + onActed?: (() => void) | undefined; +}) { + const projects = useProjects(); + const parsed = parseChangeRequestUrl(link.url); + const project = + parsed === null + ? null + : (findProjectOnChangeRequestHost( + projects.filter((candidate) => candidate.environmentId === environmentId), + parsed, + ) ?? null); + const linked = linkedPullRequestSnapshotStatus(link); + const pr = linked?.pr ?? null; + return ( + + ); +} + +export function ThreadDetailsPrRows({ + links, + currentLink, + onOpenLink, + ...row +}: ComponentProps & { + links: ReadonlyArray; + currentLink: ThreadPullRequestLink | null; + onOpenLink: (event: ReactMouseEvent, url: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const rest = + currentLink === null + ? [] + : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links))) + .map((line) => line.link) + .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink)); + if (rest.length === 0) return ; + + return ( + <> + + {expanded + ? rest.map((link) => ( + onOpenLink(event, link.url)} + onActed={row.onActed} + /> + )) + : null} + + + ); +}