Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -735,7 +735,10 @@ export function BranchToolbarBranchSelector({
</ComboboxTrigger>
</span>
{displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
<ThreadDetailsPrRow
<ThreadDetailsPrRows
links={serverThread?.pullRequests ?? []}
currentLink={currentLinkedPr}
onOpenLink={openPrLink}
environmentId={environmentId}
pr={displayedPr}
number={prNumber}
Expand Down
107 changes: 107 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPrRows.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, expect, it, vi } from "vite-plus/test";

vi.mock("./ThreadDetailsPrRow", () => ({
ThreadDetailsPrRow: ({ number }: { number: number }) => <span data-row={String(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<ThreadPullRequestLink>, current: ThreadPullRequestLink) {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
act(() => {
renderer = create(
<ThreadDetailsPrRows
links={links}
currentLink={current}
onOpenLink={vi.fn()}
environmentId={EnvironmentId.make("environment")}
pr={null}
number={current.number}
reference={current}
status={null}
project={null}
label={`#${current.number}`}
openAriaLabel="Open pull request"
onOpen={vi.fn()}
/>,
);
});
}

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();
});
109 changes: 109 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPrRows.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) => 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 (
<ThreadDetailsPrRow
environmentId={environmentId}
pr={pr}
number={link.number}
reference={link}
status={prStatusIndicator(pr, linked?.sourceControlProvider)}
project={project}
label={`#${link.number}${link.snapshot === null ? "" : `: ${link.snapshot.title}`}`}
openAriaLabel={link.url}
onOpen={onOpen}
{...(onActed ? { onActed } : {})}
/>
);
}

export function ThreadDetailsPrRows({
links,
currentLink,
onOpenLink,
...row
}: ComponentProps<typeof ThreadDetailsPrRow> & {
links: ReadonlyArray<ThreadPullRequestLink>;
currentLink: ThreadPullRequestLink | null;
onOpenLink: (event: ReactMouseEvent<HTMLElement>, 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 <ThreadDetailsPrRow {...row} />;

return (
<>
<ThreadDetailsPrRow {...row} />
{expanded
? rest.map((link) => (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<ThreadDetailsPrLinkRow
key={threadPullRequestKeyOf(link)}
environmentId={row.environmentId}
link={link}
onOpen={(event) => onOpenLink(event, link.url)}
onActed={row.onActed}
/>
))
: null}
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(!expanded)}
className={cn(
THREAD_DETAILS_PANEL_ROW_CLASS,
"w-full text-muted-foreground/70 hover:text-foreground/80 active:scale-100",
Comment thread
Bil0000 marked this conversation as resolved.
)}
>
{expanded ? (
<MinusIcon aria-hidden className="-mx-0.5 size-4 shrink-0" />
) : (
<PlusIcon aria-hidden className="-mx-0.5 size-4 shrink-0" />
)}
{expanded ? "Show less" : `Show ${rest.length} more`}
</Button>
</>
);
}
Loading