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
66 changes: 62 additions & 4 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
rankPullRequestMatches,
rankPullRequestsByMergeReadiness,
scorePullRequestMatch,
sortPullRequestGroups,
retainVisiblePullRequestStatsBatches,
withDiffStat,
resolveProjectScope,
Expand Down Expand Up @@ -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],
]);
});

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
46 changes: 44 additions & 2 deletions apps/web/src/components/pullRequest/pullRequestList.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement<Entry extends ScopedEntry>(
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] }));
}
Expand Down Expand Up @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority<Entry extends PullRequestListE
const byRecency = (left: Entry, right: Entry) => 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
)
Expand Down Expand Up @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEn
});
}

/** Keeps authored work first while applying the selected ordering inside every involvement group. */
export function sortPullRequestGroups<Entry extends PullRequestListEntry>(
groups: ReadonlyArray<PullRequestGroup<Entry>>,
sort: PullRequestListSort,
searchText: string,
hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
): ReadonlyArray<PullRequestGroup<Entry>> {
const sortWithinGroups = (rank: (entries: ReadonlyArray<Entry>) => ReadonlyArray<Entry>) =>
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
Expand Down
52 changes: 8 additions & 44 deletions apps/web/src/routes/_chat.pull-requests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import {
pullRequestEntryKey,
pullRequestEntryViewer,
rankPullRequestMatches,
rankPullRequestsByMergeReadiness,
sortPullRequestGroups,
pullRequestEnvironmentSetKey,
readPullRequestListSnapshot,
resolveProjectScope,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading