From c4062a80d9d9b15ed4e3f3d154d24b333e6763d5 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:06:26 +0200 Subject: [PATCH 1/2] feat(web): show linked PRs in workspace card --- .../BranchToolbarBranchSelector.tsx | 7 +- .../chat/ThreadDetailsPrRows.test.tsx | 107 ++++++++++++++++++ .../components/chat/ThreadDetailsPrRows.tsx | 102 +++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx create mode 100644 apps/web/src/components/chat/ThreadDetailsPrRows.tsx 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..e46faae09433 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -0,0 +1,102 @@ +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 { useProjects } from "~/state/entities"; + +import { pullRequestListLines } from "../pullRequest/pullRequestListLines"; +import { linkedPullRequestSnapshotStatus, prStatusIndicator } from "../ThreadStatusIndicators"; +import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; + +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} + + + ); +} From dcd47c56f067c5d3ed4ddda6e05112e81153aa48 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:14:14 +0200 Subject: [PATCH 2/2] fix(web): use shared button for PR list toggle --- .../src/components/chat/ThreadDetailsPrRows.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index e46faae09433..73de367107e2 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -8,11 +8,14 @@ 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, @@ -85,10 +88,14 @@ export function ThreadDetailsPrRows({ /> )) : null} - + ); }