diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 08757b275b7e4df1e276add98257e6bcf1790ce7..b3fc1da8d0a3de1e8d92becda275d279591e4e5c 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 576f771e7e7efe881e23a1b18212e5249cc5bda4..af1bd6ab4fbe7b055e53f779d4360f2822a73766 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 09701423fae09a691475ddcc483964f91530b76d..29e6b05c0b19918aa9f60291ccb4eef1a6b1dd11 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 937cf91c9037d20e4e749f87b60481d5d7814833..5727be12ae869b6dfbc4e3af90fb730237657eec 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.