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
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ vi.mock(import("~/state/pullRequests"), async (original) => {
vi.mock(import("~/state/query"), async (original) => ({
...(await original()),
useEnvironmentQuery: ((query: unknown) => ({
data: query === "detail" ? currentDetail : query === queries.diff ? diff : null,
data:
query === "detail"
? (activityDetail ?? currentDetail)
: query === "activity"
? activityDetail
: query === queries.diff
? diff
: null,
dataUpdatedAt: 0,
failure: null,
isPending: false,
Expand All @@ -109,33 +116,41 @@ vi.mock(import("~/state/usePullRequestStack"), async (original) => ({
refresh,
})) as never,
}));
// The summary stand-in exposes its panel actions and nested scrolling.
vi.mock(import("./PullRequestSummaryTab"), async (original) => ({
...(await original()),
PullRequestSummaryTab: (({
onFixFinding,
scrollerRef,
}: ComponentProps<typeof import("./PullRequestSummaryTab").PullRequestSummaryTab>) => (
<div ref={scrollerRef} data-testid="summary-scroller" className="h-full overflow-y-auto">
<button
type="button"
onClick={() =>
onFixFinding?.({
kind: "check",
check: { name: "Unit tests", status: "failure", description: "Test failed", url: null },
})
}
>
Fix check
</button>
<div data-testid="nested-scroller" className="h-40 overflow-y-auto">
<div className="h-[2000px]" />
</div>
<div className="h-[3000px]" />
</div>
)) as never,
}));

// Action and scroll tests use stand-ins; activity tests render the real Summary.
vi.mock(import("./PullRequestSummaryTab"), async (original) => {
const actual = await original();
return {
...actual,
PullRequestSummaryTab: ((props: ComponentProps<typeof actual.PullRequestSummaryTab>) => {
if (activityDetail !== null) return <actual.PullRequestSummaryTab {...props} />;
const { onFixFinding, scrollerRef } = props;
return (
<div ref={scrollerRef} data-testid="summary-scroller" className="h-full overflow-y-auto">
<button
type="button"
onClick={() =>
onFixFinding?.({
kind: "check",
check: {
name: "Unit tests",
status: "failure",
description: "Test failed",
url: null,
},
})
}
>
Fix check
</button>
<div data-testid="nested-scroller" className="h-40 overflow-y-auto">
<div className="h-[2000px]" />
</div>
<div className="h-[3000px]" />
</div>
);
}) as never,
};
});
vi.mock(import("../ui/toast"), async (original) => {
const actual = await original();
return {
Expand Down Expand Up @@ -201,6 +216,7 @@ const detail: PullRequestDetailView = {
};

let currentDetail = detail;
let activityDetail: PullRequestDetailView | null = null;

// Enough lines to exercise the panel's title folding with the real virtualized Code tab.
const diff = {
Expand Down Expand Up @@ -256,6 +272,7 @@ function render(props: Partial<ComponentProps<typeof PullRequestDetailPanel>> =
}

beforeEach(() => {
activityDetail = null;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
currentDetail = detail;
useComposerDraftStore.setState({ draftsByThreadKey: {} });
Expand Down Expand Up @@ -607,3 +624,107 @@ it("shows the current checkout recovery detail on the first failure and on retry
second.detail,
]);
});

it("keeps discussion counts and Timeline expansion in sync with activity refreshes", async () => {
activityDetail = {
...detail,
provider: SourceControlProviderKind.make("gitlab"),
url: "https://gitlab.com/owner/repo/-/merge_requests/1",
comments: [],
commentCount: 0,
commentsTruncated: true,
reviewThreads: [
{
id: "discussion-1",
path: "src/index.ts",
line: 1,
side: "right",
isResolved: false,
isOutdated: false,
comments: [
{
id: "note-1",
author: detail.author,
body: "Discussion finding",
createdAt: "2026-09-01T00:01:00Z",
url: "https://gitlab.com/owner/repo/-/merge_requests/1#note_1",
},
{
id: "note-2",
author: detail.author,
body: "Discussion reply",
createdAt: "2026-09-01T00:02:00Z",
url: "https://gitlab.com/owner/repo/-/merge_requests/1#note_2",
},
],
},
],
};
await render({ reference: activityDetail });
await expect.element(page.getByText("Discussion finding", { exact: true })).toBeVisible();
await expect.element(page.getByText("Discussion reply", { exact: true })).toBeVisible();
expect
.soft(page.getByRole("button", { name: /^Comments \(/ }).element().textContent)
.toBe("Comments (2)");
const warning = page.getByText(/open it on the host to read the rest/).element().textContent;
expect.soft(warning).toMatch(/\b2\b/);
expect.soft(warning).not.toMatch(/most recent/i);

await tab("Timeline").click();
await expect.element(page.getByText("Discussion finding", { exact: true }).last()).toBeVisible();
await expect.element(page.getByText("Discussion reply", { exact: true }).last()).toBeVisible();
expect.soft(page.getByLabelText(/^[0-9]+ comments?$/).element().textContent).toBe("2");

const threadToggle = page.getByRole("button", { name: /^src\/index\.ts:1/ });
const expectThread = (expanded: boolean, status: string | null) => {
const toggle = threadToggle.element();
if (status) expect(toggle.textContent).toContain(status);
else expect(toggle.textContent).not.toMatch(/Resolved|Review dismissed/);
const visibleBody = (toggle.parentElement as HTMLElement).innerText;
expect({
expanded: toggle.getAttribute("aria-expanded"),
findingVisible: visibleBody.includes("Discussion finding"),
replyVisible: visibleBody.includes("Discussion reply"),
}).toEqual({
expanded: String(expanded),
findingVisible: expanded,
replyVisible: expanded,
});
};
const refreshActivity = async (isResolved: boolean, reviewState: string | null = null) => {
await tab("Code").click();
await expect
.element(page.getByRole("button", { name: "Expand all files", exact: true }))
.toBeVisible();
const current = activityDetail!;
activityDetail = {
...current,
comments: current.reviewThreads.flatMap((thread) =>
thread.comments.map((comment) => ({
...comment,
kind: "review-comment" as const,
path: thread.path,
reviewState,
})),
),
reviewThreads: current.reviewThreads.map((thread) => ({ ...thread, isResolved })),
};
await tab("Timeline").click();
};

expectThread(true, null);
await refreshActivity(true);
expectThread(false, "Resolved");
await threadToggle.click();
expectThread(true, "Resolved");
await refreshActivity(true);
expectThread(true, "Resolved");
await threadToggle.click();
expectThread(false, "Resolved");
await refreshActivity(false);
expectThread(true, null);
await refreshActivity(false, "DISMISSED");
expectThread(false, "Review dismissed");
await refreshActivity(false);
expectThread(true, null);
});
10 changes: 9 additions & 1 deletion apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,15 @@ function usePullRequestDetail(
author: activity?.author ?? coreDetail.author,
reviewers: activity?.reviewers ?? coreDetail.reviewers,
comments: activity?.comments ?? [],
commentCount: activity?.commentCount ?? 0,
commentCount: Math.max(
activity?.commentCount ?? 0,
new Set([
...(activity?.comments.map((comment) => comment.id) ?? []),
...(activity?.reviewThreads.flatMap((thread) =>
thread.comments.map((comment) => comment.id),
) ?? []),
]).size,
),
commentsTruncated: activity?.commentsTruncated ?? false,
reviewThreads: activity?.reviewThreads ?? [],
commits: activity?.commits ?? [],
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestMoreReplies.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { PullRequestReplyAvailability } from "./pullRequestDetail.logic";

export function PullRequestMoreReplies({
moreReplies,
hasMoreReplies,
url,
}: PullRequestReplyAvailability) {
if (!hasMoreReplies) return null;
return (
<div className="flex flex-wrap items-baseline gap-2 py-1 text-xs text-muted-foreground">
<span>
{moreReplies > 0
? `${moreReplies.toLocaleString()} more ${moreReplies === 1 ? "reply" : "replies"}`
: "More replies"}
</span>
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground hover:underline"
>
View on host
</a>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts";
import {
EnvironmentId,
ProjectId,
type PullRequestDetailView,
type PullRequestComment,
} from "@t3tools/contracts";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
Expand Down Expand Up @@ -219,3 +224,62 @@ it("hides bot and resolved comments until the filter shows them, and remembers t
await userEvent.click(filterButton());
expect(filterItem("Bot comments").getAttribute("aria-checked")).toBe("true");
});

it("reveals whole older conversations with honest partial reply counts and a host link", async () => {
const comment = (index: number): PullRequestComment => ({
id: `comment-${index}`,
kind: "issue-comment",
author: detail.author,
body: `Comment ${index}`,
createdAt: `2026-09-01T00:00:${String(index).padStart(2, "0")}Z`,
url: `${detail.url}#comment-${index}`,
path: null,
reviewState: null,
});
const finding = { ...comment(0), kind: "review-comment" as const, body: "Review finding" };
const reply = { ...comment(12), kind: "review-comment" as const, body: "Loaded reply" };
const thread = {
id: "partial-thread",
path: "src/index.ts",
line: 1,
side: "right" as const,
isResolved: true,
isOutdated: false,
comments: [finding, reply],
nextCommentsCursor: "next-page",
};
const value: PullRequestDetailView = {
...detail,
comments: [finding, ...Array.from({ length: 10 }, (_, index) => comment(index + 1)), reply],
commentCount: 15,
reviewThreads: [{ ...thread, commentCount: 5 }],
};
await render(value);
await toggleFilter("Resolved or dismissed");
await userEvent.keyboard("{Escape}");
const older = [...container.querySelectorAll("button")].find((button) =>
button.textContent?.startsWith("Show 1 older"),
)!;
expect(older).toBeDefined();
expect.soft(older.textContent).toMatch(/1 older conversation.*1 hidden/);
expect(commentTexts()).not.toContain(reply.body);
await userEvent.click(older);
const resolved = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Resolved",
)!;
expect(resolved.getAttribute("aria-expanded")).toBe("false");
expect(commentTexts()).not.toContain(reply.body);
expect.soft(visibleText()).toContain("4 replies");
await userEvent.click(resolved);
expect(commentTexts().slice(-2)).toEqual([finding.body, reply.body]);
expect.soft(visibleText()).toContain("3 more replies");
const hostLink = [...container.querySelectorAll("a")].find(
(link) =>
link.href === finding.url && /host/i.test(link.textContent ?? "") && link.checkVisibility(),
);
expect.soft(hostLink, "visible link to the unloaded replies on the host").toBeDefined();

await render({ ...value, reviewThreads: [thread] });
expect.soft(visibleText()).toMatch(/more replies/i);
expect(visibleText()).not.toContain("3 more replies");
});
Loading
Loading