authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-21 14:39:40-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 14:39:40-03:00
log371b52d9dad76876f84609a4cd0f80eaa757b69c
tree4df0ba1762f873e965d67f37ba4dcc8edf59c0bd
parent1de563c1491c7d82563e4553bf5bf689ce6adbb9
signature Signed by PGP key B5690EEEBB952194

feat(web): answer pull request actions on the row at once (#12843)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 367 insertions(+), 15 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+9-2
......@@ -458,7 +458,12 @@ export function PullRequestDetailPanel({
458458 * An action changed this pull request on the host, so a list showing it is now out of date.
459459 * Told rather than assumed: only the page knows whether it is showing one.
460460 */
461 onActed?: () => void;
461 /**
462 * Each host action as it goes: "sent" the moment it leaves, so a list can answer before the
463 * host does; "done" or "failed" when the host has spoken. Undefined for one the caller cannot
464 * name, which is only ever "done".
465 */
466 onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void;
462467 /** Page-owned detail columns use this to clear the selected pull request. */
463468 onClose?: () => void;
464469 /**
......@@ -961,6 +966,7 @@ export function PullRequestDetailPanel({
961966 method?: PullRequestMergeMethod,
962967 updateMethod?: PullRequestUpdateMethod,
963968 ) => {
969 onActed?.(action, "sent");
964970 const result = await runAction({
965971 environmentId,
966972 input: {
......@@ -988,6 +994,7 @@ export function PullRequestDetailPanel({
988994 title: ACTION_FAILURE_LABELS[action],
989995 description: readableFailure(failure, hint),
990996 });
997 onActed?.(action, "failed");
991998 return false;
992999 }
9931000 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
......@@ -1001,7 +1008,7 @@ export function PullRequestDetailPanel({
10011008 } else {
10021009 refreshDetail();
10031010 }
1004 onActed?.();
1011 onActed?.(action, "done");
10051012 return true;
10061013 };
10071014
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+94
......@@ -32,6 +32,10 @@ import {
3232 resolveQueryEnvironmentIds,
3333 resolveSelectedEnvironmentId,
3434 type EnvironmentPullRequestEntry,
35 applyPullRequestOverrides,
36 pullRequestOverrideAfterAction,
37 reusePullRequestEntries,
38 settlePullRequestOverrides,
3539} from "./pullRequestList.logic";
3640import {
3741 pullRequestListPreferences,
......@@ -1600,3 +1604,93 @@ describe("the priority groups against a paginated feed", () => {
16001604 ).toEqual([6123]);
16011605 });
16021606});
1607
1608describe("pull request list overrides", () => {
1609 const entry = (number: number, state: "open" | "closed" | "merged") =>
1610 ({
1611 host: "github.com",
1612 repository: "pingdotgg/t3code",
1613 number,
1614 state,
1615 isDraft: false,
1616 updatedAt: "2026-07-01T00:00:00Z",
1617 labels: [],
1618 }) as unknown as PullRequestListEntry;
1619 const key = (row: { number: number }) => `#${row.number}`;
1620
1621 it("maps the actions that change a row's state and nothing else", () => {
1622 const now = new Date("2026-07-02T00:00:00Z");
1623 expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", now, 7)).toEqual({
1624 state: "closed",
1625 updatedAt: "2026-07-02T00:00:00.000Z",
1626 token: 7,
1627 at: now.getTime(),
1628 });
1629 expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", now, 1)?.state).toBe(
1630 "open",
1631 );
1632 expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", now, 1)?.state).toBe("merged");
1633 expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", now, 1)?.isDraft).toBe(true);
1634 expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", now, 1)).toBeNull();
1635 });
1636
1637 it("writes the override over the row and drops it from a list whose state it left", () => {
1638 const rows = [entry(1, "open"), entry(2, "open")];
1639 const overrides = new Map([
1640 ["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at: 0 }],
1641 ]);
1642 expect(
1643 applyPullRequestOverrides(rows, overrides, key, "open").map((row) => row.number),
1644 ).toEqual([2]);
1645 const all = applyPullRequestOverrides(rows, overrides, key, "all");
1646 expect(all.map((row) => [row.number, row.state])).toEqual([
1647 [1, "closed"],
1648 [2, "open"],
1649 ]);
1650 expect(applyPullRequestOverrides(rows, new Map(), key, "open")).toBe(rows);
1651 });
1652
1653 it("hands back the held object for a row a refresh did not change", () => {
1654 const previous = [entry(1, "open"), entry(2, "open")];
1655 const next = [{ ...entry(1, "open") }, { ...entry(2, "open"), state: "merged" as const }];
1656 const reused = reusePullRequestEntries(previous, next, key);
1657 expect(reused[0]).toBe(previous[0]);
1658 expect(reused[1]).toBe(next[1]);
1659 expect(
1660 reusePullRequestEntries(previous, [{ ...entry(1, "open") }, { ...entry(2, "open") }], key),
1661 ).toBe(previous);
1662 });
1663});
1664
1665describe("pull request list override settlement", () => {
1666 const entry = (number: number, state: "open" | "closed" | "merged") =>
1667 ({ number, state, isDraft: false, labels: [] }) as unknown as PullRequestListEntry;
1668 const key = (row: { number: number }) => `#${row.number}`;
1669
1670 it("keeps an override until an answer agrees with it", () => {
1671 const at = 1_000_000;
1672 const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at };
1673 const overrides = new Map([["#1", closed]]);
1674 // A read from before the action still says open: the override stands.
1675 expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 5_000)).toBe(
1676 overrides,
1677 );
1678 // Absent from the answer says nothing: the row may live in another group or page.
1679 expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, at + 5_000).size).toBe(1);
1680 // Present as closed: confirmed.
1681 expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, at + 5_000).size).toBe(
1682 0,
1683 );
1684 // Present as open a good while later: the host's news, which outranks the note.
1685 expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 90_000).size).toBe(
1686 0,
1687 );
1688 });
1689
1690 it("does not hand back the old order when only the order changed", () => {
1691 const previous = [entry(1, "open"), entry(2, "open")];
1692 const swapped = reusePullRequestEntries(previous, [entry(2, "open"), entry(1, "open")], key);
1693 expect(swapped).not.toBe(previous);
1694 expect(swapped.map((row) => row.number)).toEqual([2, 1]);
1695 });
1696});
apps/web/src/components/pullRequest/pullRequestList.logic.ts+126
......@@ -9,6 +9,7 @@ import {
99} from "@t3tools/contracts";
1010import type {
1111 ProjectId,
12 PullRequestAction,
1213 PullRequestActor,
1314 PullRequestDiffStat,
1415 PullRequestInvolvement,
......@@ -16,6 +17,7 @@ import type {
1617 PullRequestListCursors,
1718 PullRequestListFilters,
1819 PullRequestListState,
20 PullRequestState,
1921} from "@t3tools/contracts";
2022
2123import { toSortableTimestamp } from "../../lib/threadSort";
......@@ -1140,3 +1142,127 @@ export function withDiffStat<
11401142 const stat = statsByRow.get(pullRequestDiffStatKey(entry));
11411143 return stat === undefined ? entry : { ...entry, ...stat };
11421144}
1145
1146/**
1147 * What a row should say the moment an action is sent, before any host has answered. The host
1148 * is the record and a later read replaces this, but the reader pressed the button and should
1149 * see the row answer at once: a closed pull request leaves an "open" list on the click, not
1150 * after the reads that follow.
1151 */
1152export interface PullRequestListOverride {
1153 readonly state: PullRequestState;
1154 readonly isDraft?: boolean;
1155 readonly updatedAt: string;
1156 /** Which action wrote it, so a failure takes back its own note and not a later one's. */
1157 readonly token: number;
1158 /** When it was written, in the reader's clock. */
1159 readonly at: number;
1160}
1161
1162export function pullRequestOverrideAfterAction(
1163 entry: Pick<PullRequestListEntry, "state" | "isDraft">,
1164 action: PullRequestAction,
1165 now: Date,
1166 token: number,
1167): PullRequestListOverride | null {
1168 const stamp = { updatedAt: now.toISOString(), token, at: now.getTime() };
1169 switch (action) {
1170 case "close":
1171 return { state: "closed", ...stamp };
1172 case "reopen":
1173 return { state: "open", ...stamp };
1174 case "merge":
1175 return { state: "merged", ...stamp };
1176 case "draft":
1177 return { state: entry.state, isDraft: true, ...stamp };
1178 case "ready":
1179 return { state: entry.state, isDraft: false, ...stamp };
1180 default:
1181 return null;
1182 }
1183}
1184
1185/** The rows with their pending answers written over them, and the ones the list's state filter no longer holds dropped. */
1186export function applyPullRequestOverrides<Entry extends PullRequestListEntry>(
1187 entries: ReadonlyArray<Entry>,
1188 overrides: ReadonlyMap<string, PullRequestListOverride>,
1189 keyOf: (entry: Entry) => string,
1190 state: PullRequestListState,
1191): ReadonlyArray<Entry> {
1192 if (overrides.size === 0) return entries;
1193 const out: Entry[] = [];
1194 for (const entry of entries) {
1195 const override = overrides.get(keyOf(entry));
1196 if (override === undefined) {
1197 out.push(entry);
1198 continue;
1199 }
1200 if (state !== "all" && override.state !== state) continue;
1201 out.push({ ...entry, ...override });
1202 }
1203 return out;
1204}
1205
1206/**
1207 * A fresh answer with the rows it did not change handed back as the objects already held, so a
1208 * memoized row whose data is the same does not render again. Every refresh otherwise rebuilds
1209 * every entry, and a hundred rows repaint for the one that moved.
1210 */
1211export function reusePullRequestEntries<Entry extends PullRequestListEntry>(
1212 previous: ReadonlyArray<Entry>,
1213 next: ReadonlyArray<Entry>,
1214 keyOf: (entry: Entry) => string,
1215): ReadonlyArray<Entry> {
1216 if (previous.length === 0) return next;
1217 const held = new Map(previous.map((entry) => [keyOf(entry), entry]));
1218 let reused = 0;
1219 const out = next.map((entry) => {
1220 const before = held.get(keyOf(entry));
1221 if (before !== undefined && JSON.stringify(before) === JSON.stringify(entry)) {
1222 reused += 1;
1223 return before;
1224 }
1225 return entry;
1226 });
1227 return reused === next.length &&
1228 previous.length === next.length &&
1229 previous.every((entry, index) => keyOf(entry) === keyOf(next[index]!))
1230 ? previous
1231 : out;
1232}
1233
1234/** How long a read that disagrees is taken for a stale one rather than for news. */
1235const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000;
1236
1237/**
1238 * The overrides an answer has confirmed, dropped; the rest kept. A read that started before
1239 * the action can land after it and still say the old thing, so an override is not cleared
1240 * because an answer arrived but because the answer agrees: the row is there in the state the
1241 * override said. A row that is absent says nothing — the authored and reviewing groups are read
1242 * apart from the feed, and a page is only a page — so absence never confirms. A row present in
1243 * another state is taken for a stale read for a minute, and for the host's news after that,
1244 * which is how a pull request reopened elsewhere comes back.
1245 */
1246export function settlePullRequestOverrides<Entry extends PullRequestListEntry>(
1247 overrides: ReadonlyMap<string, PullRequestListOverride>,
1248 answered: ReadonlyArray<Entry>,
1249 keyOf: (entry: Entry) => string,
1250 now: number,
1251): ReadonlyMap<string, PullRequestListOverride> {
1252 if (overrides.size === 0) return overrides;
1253 const byKey = new Map(answered.map((entry) => [keyOf(entry), entry]));
1254 const kept = new Map<string, PullRequestListOverride>();
1255 for (const [key, override] of overrides) {
1256 const row = byKey.get(key);
1257 if (row === undefined) {
1258 kept.set(key, override);
1259 continue;
1260 }
1261 const agrees =
1262 row.state === override.state &&
1263 (override.isDraft === undefined || row.isDraft === override.isDraft);
1264 const outranked = now - override.at > PULL_REQUEST_OVERRIDE_TRUST_MS;
1265 if (!agrees && !outranked) kept.set(key, override);
1266 }
1267 return kept.size === overrides.size ? overrides : kept;
1268}
apps/web/src/routes/_chat.pull-requests.tsx+138-13
......@@ -4,6 +4,7 @@ import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contr
44import type {
55 EnvironmentId,
66 ProjectId,
7 PullRequestAction,
78 PullRequestInvolvement,
89 PullRequestListCursors,
910 PullRequestListFilters,
......@@ -77,6 +78,11 @@ import {
7778 type PullRequestStatsPolicy,
7879 type PullRequestStatsScope,
7980 type PullRequestPartitionsSnapshot,
81 applyPullRequestOverrides,
82 type PullRequestListOverride,
83 pullRequestOverrideAfterAction,
84 reusePullRequestEntries,
85 settlePullRequestOverrides,
8086} from "../components/pullRequest/pullRequestList.logic";
8187import {
8288 pullRequestListPreferences,
......@@ -914,6 +920,36 @@ function PullRequestsRouteView() {
914920 key: string;
915921 entries: ReadonlyArray<EnvironmentPullRequestEntry>;
916922 } | null>(null);
923 // What the reader just did to a row, shown before any host confirms it. A closed pull request
924 // leaves an "open" list on the click; the reads that follow are slow, and until one lands the
925 // row says what was asked of it. Cleared when a whole-page answer arrives after the action.
926 const [overrides, setOverrides] = useState<ReadonlyMap<string, PullRequestListOverride>>(
927 () => new Map(),
928 );
929 const overrideToken = useRef(0);
930 /** Writes the action's outcome onto the row; the token names this write for a later rollback. */
931 const overrideEntry = (
932 entry: EnvironmentPullRequestEntry,
933 action: PullRequestAction,
934 ): number | null => {
935 const token = ++overrideToken.current;
936 const override = pullRequestOverrideAfterAction(entry, action, new Date(), token);
937 if (override === null) return null;
938 setOverrides((current) => new Map(current).set(pullRequestEntryKey(entry), override));
939 return token;
940 };
941 /** A rollback for one write only: a later action's note over the same row is left alone. */
942 const revertOverride = (key: string, token: number | null) => {
943 if (token === null) return;
944 setOverrides((current) => {
945 if (current.get(key)?.token !== token) return current;
946 const next = new Map(current);
947 next.delete(key);
948 return next;
949 });
950 };
951 /** The detail panel's own writes, by row, so its failure takes back its own note. */
952 const detailOverrideTokens = useRef(new Map<string, number | null>());
917953 // A reload recreates the registry the queries live in, so with nothing held the page would
918954 // cold-start into skeletons even though almost every row is unchanged. The last answer for
919955 // this set of environments is kept across reloads and hydrated here as the carried rows: they
......@@ -1077,8 +1113,20 @@ function PullRequestsRouteView() {
10771113 // since the last read at the bottom of the page — below rows a week older — where "the
10781114 // latest" is exactly what a refresh was for. The host answers in the order the page
10791115 // reads, so its order stands; a row that moved was updated, and moving is the news.
1080 return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentParsed.text) };
1116 return {
1117 key: filterKey,
1118 entries: reusePullRequestEntries(
1119 previous.entries,
1120 rankPullRequestMatches(answered.entries, sentParsed.text),
1121 pullRequestEntryKey,
1122 ),
1123 };
10811124 });
1125 // The host's word outranks the reader's, once it has actually said it: an override is
1126 // cleared by an answer that agrees with it, not by any answer that happens to land.
1127 setOverrides((current) =>
1128 settlePullRequestOverrides(current, answered.entries, pullRequestEntryKey, Date.now()),
1129 );
10821130 }, [
10831131 answered,
10841132 filterKey,
......@@ -1455,10 +1503,34 @@ function PullRequestsRouteView() {
14551503 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));
14561504 }, [statsQuery.stats]);
14571505 const displayGroups = useMemo(() => {
1458 const enriched = groups.map((group) => ({
1459 ...group,
1460 entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),
1461 }));
1506 // The reader's pending answers go on here, after grouping: the authored and reviewing
1507 // groups are read separately from the feed, and a row closed a moment ago has to leave
1508 // whichever group it was in.
1509 const enriched = groups.map((group) => {
1510 const answered = applyPullRequestOverrides(
1511 group.entries.map((entry) => withDiffStat(entry, statsByRow)),
1512 overrides,
1513 pullRequestEntryKey,
1514 search.state,
1515 );
1516 // A row whose draft flag just changed has to pass the local filters again: a draft
1517 // filter that let it in may not let the new one in.
1518 return {
1519 ...group,
1520 entries:
1521 hasLocalFilters && overrides.size > 0
1522 ? answered.filter(
1523 (entry) =>
1524 !overrides.has(pullRequestEntryKey(entry)) ||
1525 matchesPullRequestFilters(
1526 entry,
1527 localFilters,
1528 pullRequestEntryViewer(entry, viewers),
1529 ),
1530 )
1531 : answered,
1532 };
1533 });
14621534 // Searching keeps its relevance order and priority groups unless the reader explicitly asks
14631535 // for another sort. The readiness queue is the default browse order, not a way to bury a
14641536 // closer text match.
......@@ -1470,7 +1542,29 @@ function PullRequestsRouteView() {
14701542 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
14711543 search.involvement,
14721544 );
1473 }, [groups, search.involvement, sort, statsByRow, typedParsed.text]);
1545 }, [
1546 groups,
1547 hasLocalFilters,
1548 localFilters,
1549 overrides,
1550 search.involvement,
1551 search.state,
1552 sort,
1553 statsByRow,
1554 typedParsed.text,
1555 viewers,
1556 ]);
1557 /** What is actually on screen once the reader's pending answers are on the rows. */
1558 const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0);
1559 const heldPullRequestsBySurface = useMemo(
1560 () =>
1561 new Map(
1562 groups.flatMap((group) =>
1563 group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const),
1564 ),
1565 ),
1566 [groups],
1567 );
14741568 const listedPullRequestsBySurface = useMemo(
14751569 () =>
14761570 new Map(
......@@ -1645,7 +1739,7 @@ function PullRequestsRouteView() {
16451739 // so that case waits with the skeletons rather than answering for the hosts. A search says so
16461740 // in its own words and is left to.
16471741 const carriedToNothing =
1648 showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0;
1742 showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0;
16491743 const listBody = (
16501744 <>
16511745 {!capabilityKnown ? (
......@@ -1657,7 +1751,7 @@ function PullRequestsRouteView() {
16571751 />
16581752 ) : firstLoad ? (
16591753 <PullRequestListGhost rows={7} />
1660 ) : listQuery.error && entries.length === 0 ? (
1754 ) : listQuery.error && shownCount === 0 ? (
16611755 <PullRequestsUnavailableState
16621756 error={listQuery.error}
16631757 refreshing={listQuery.isPending}
......@@ -1665,7 +1759,7 @@ function PullRequestsRouteView() {
16651759 />
16661760 ) : carriedToNothing ? (
16671761 <PullRequestListGhost rows={7} />
1668 ) : entries.length === 0 ? (
1762 ) : shownCount === 0 ? (
16691763 <PullRequestListEmptyState
16701764 hasProjects={!projectsKnown || projects.length > 0}
16711765 refreshing={refreshing}
......@@ -1724,7 +1818,7 @@ function PullRequestsRouteView() {
17241818 </div>
17251819 )}
17261820
1727 {listQuery.error && entries.length > 0 ? (
1821 {listQuery.error && shownCount > 0 ? (
17281822 <div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs">
17291823 <span>{listQuery.error} Showing the last pull requests loaded.</span>
17301824 <Button size="xs" variant="outline" onClick={() => listQuery.refresh()}>
......@@ -2067,9 +2161,40 @@ function PullRequestsRouteView() {
20672161 refreshToken={detailRefreshToken}
20682162 // Host actions can change both readiness and diff size, so refresh the counts
20692163 // alongside the list. The panel already refreshes itself after each action.
2070 onActed={() => {
2071 // Mutations already invalidate the host's affected caches.
2072 refreshListAndStats(undefined, panelEnvironmentId);
2164 onActed={(action, phase = "done") => {
2165 // An action that only moves a row's state is written onto the row as it is
2166 // sent, and taken back if the host refuses; the host invalidates its caches,
2167 // so the next scheduled read confirms it. The rest change what the counts and
2168 // checks say, and those need the reads once they are done.
2169 // From every row held, not the ones on screen: a pull request closed from an
2170 // open list has left the screen, and reopening it has to find it anyway.
2171 const acted = heldPullRequestsBySurface.get(
2172 pullRequestListEntryId(renderedPullRequestSurface),
2173 );
2174 const stateOnly =
2175 action !== undefined &&
2176 acted !== undefined &&
2177 pullRequestOverrideAfterAction(acted, action, new Date(), 0) !== null;
2178 if (stateOnly) {
2179 const key = pullRequestEntryKey(acted);
2180 // A merge is written on once the host has done it, since a host that only
2181 // queues one leaves the pull request open; the rest go on as they are sent.
2182 if (phase === "sent" && action !== "merge") {
2183 detailOverrideTokens.current.set(key, overrideEntry(acted, action));
2184 }
2185 // A merge wrote nothing on the way out, so its failure has nothing to take
2186 // back; an earlier action's note on the same row is left standing.
2187 if (phase === "failed" && action !== "merge") {
2188 revertOverride(key, detailOverrideTokens.current.get(key) ?? null);
2189 }
2190 if (phase !== "sent") detailOverrideTokens.current.delete(key);
2191 if (phase === "done" && action === "merge") {
2192 overrideEntry(acted, action);
2193 refreshListAndStats(undefined, panelEnvironmentId);
2194 }
2195 return;
2196 }
2197 if (phase === "done") refreshListAndStats(undefined, panelEnvironmentId);
20732198 }}
20742199 />
20752200 </RightPanelTabs>