diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 08757b275b7e..b3fc1da8d0a3 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -23,6 +23,7 @@ import { rankPullRequestMatches, rankPullRequestsByMergeReadiness, scorePullRequestMatch, + sortPullRequestGroups, retainVisiblePullRequestStatsBatches, withDiffStat, resolveProjectScope, @@ -328,8 +329,8 @@ describe("pull request grouping", () => { VIEWERS, ); expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ - ["reviewRequested", 1], ["authored", 1], + ["reviewRequested", 1], ]); }); @@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => { rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), ).toEqual([2, 1, 3]); }); + + it("keeps authored work first and ranks each group by readiness", () => { + const authoredWaiting = entry({ number: 1, checksState: "pending" }); + const authoredReady = entry({ + number: 2, + checksState: "passing", + reviewDecision: "approved", + }); + const otherReady = entry({ + number: 3, + checksState: "passing", + reviewDecision: "approved", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] }, + { key: "others", label: "Others", entries: [otherReady] }, + ], + "ready", + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]); + }); + + it.each([ + ["updated", [1, 2]], + ["newest", [2, 1]], + ["oldest", [1, 2]], + ["largest", [1, 2]], + ["smallest", [2, 1]], + ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => { + const olderLarger = entry({ + number: 1, + additions: 20, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + }); + const newerSmaller = entry({ + number: 2, + additions: 2, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] }, + { key: "others", label: "Others", entries: [entry({ number: 3 })] }, + ], + sort, + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order); + }); }); describe("line counts that arrive after the rows", () => { @@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => { updatedAt: "2026-06-02T00:00:00Z", }); const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]); - expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]); - expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]); - expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]); + expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]); }); it("lets the feed's copy of a partitioned row replace the partition's", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 576f771e7e7e..af1bd6ab4fbe 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -18,6 +18,9 @@ import type { PullRequestListState, } from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import type { PullRequestListSort } from "./pullRequestListPreferences"; + /** * A listed change request with the environment that read it. Nothing on a row says which machine * it came from, and the page unions every connected one — so acting on a row, refreshing it, or @@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement( buckets.others.push(entry); } } - return (["reviewRequested", "authored", "others"] as const) + return (["authored", "reviewRequested", "others"] as const) .filter((key) => buckets[key].length > 0) .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); } @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority right.updatedAt.localeCompare(left.updatedAt); return ( [ - { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, + { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "others", entries: others }, ] as const ) @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness( + groups: ReadonlyArray>, + sort: PullRequestListSort, + searchText: string, + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, +): ReadonlyArray> { + const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) => + groups.map((group) => ({ ...group, entries: rank(group.entries) })); + + if (sort === "ready") { + return searchText.trim().length === 0 + ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) + : groups; + } + if (sort === "updated") return groups; + + const timestamp = (entry: Entry) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return sortWithinGroups((entries) => + entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + ); +} + /** * A row with the line counts that arrived after it did. Only where the host left them out — a * listing that carried them is not second-guessed — and only where they have arrived, since a row diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 09701423fae0..29e6b05c0b19 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -58,7 +58,7 @@ import { pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, - rankPullRequestsByMergeReadiness, + sortPullRequestGroups, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, @@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; -import { toSortableTimestamp } from "../lib/threadSort"; import { pullRequestSurfaceId, selectActiveRightPanelSurface, @@ -1426,51 +1425,16 @@ function PullRequestsRouteView() { ...group, entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), })); - if (sort === "ready" && typedParsed.text.length === 0) { - return [ - { - key: "others" as const, - label: "", - entries: rankPullRequestsByMergeReadiness( - enriched.flatMap((group) => group.entries), - (entry) => - entry.additions + entry.deletions > 0 || - statsByRow.has(pullRequestDiffStatKey(entry)), - ), - }, - ]; - } // Searching keeps its relevance order and priority groups unless the reader explicitly asks // for another sort. The readiness queue is the default browse order, not a way to bury a // closer text match. - if (sort === "ready" || sort === "updated") return enriched; - const entries = enriched.flatMap((group) => group.entries); - const hasSize = (entry: (typeof entries)[number]) => - entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)); - const timestamp = (entry: (typeof entries)[number]) => - toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; - return [ - { - key: "others" as const, - label: "", - entries: entries.toSorted((left, right) => { - if (sort === "newest" || sort === "oldest") { - const leftCreated = toSortableTimestamp(left.createdAt); - const rightCreated = toSortableTimestamp(right.createdAt); - const measured = Number(rightCreated !== null) - Number(leftCreated !== null); - const dated = (leftCreated ?? 0) - (rightCreated ?? 0); - return ( - measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) - ); - } - const measured = Number(hasSize(right)) - Number(hasSize(left)); - const sized = left.additions + left.deletions - (right.additions + right.deletions); - return ( - measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) - ); - }), - }, - ]; + return sortPullRequestGroups( + enriched, + sort, + typedParsed.text, + (entry) => + entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), + ); }, [groups, sort, statsByRow, typedParsed.text]); const linkedSelection = useMemo( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 937cf91c9037..5727be12ae86 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,9 +41,10 @@ T3 Code works with the platforms your team already uses: - See if your current branch already has an open PR/MR - Open several reviews from the **Pull requests** page as tabs in the right panel -- By default, see passing and approved reviews first, passing reviews awaiting approval next, and - conflicting reviews last. Smaller changes come first within each readiness group, and finished - reviews follow open work when all states are visible. +- Your authored reviews stay at the top and use the selected sort within their group. By default, + see passing and approved reviews first, passing reviews awaiting approval next, and conflicting + reviews last. Smaller changes come first within each readiness group, and finished reviews follow + open work when all states are visible. - Filter the list by author or labels, rank authors by merges in the loaded results, see label and change-size context on each row, and sort the results currently shown by readiness, update time, creation time, or change size. Your filters, search, scope, and sort are restored when you return.