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({...@@ -458,7 +458,12 @@ export function PullRequestDetailPanel({
458 * An action changed this pull request on the host, so a list showing it is now out of date.458 * An action changed this pull request on the host, so a list showing it is now out of date.
459 * Told rather than assumed: only the page knows whether it is showing one.459 * Told rather than assumed: only the page knows whether it is showing one.
460 */460 */
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;
462 /** Page-owned detail columns use this to clear the selected pull request. */467 /** Page-owned detail columns use this to clear the selected pull request. */
463 onClose?: () => void;468 onClose?: () => void;
464 /**469 /**
...@@ -961,6 +966,7 @@ export function PullRequestDetailPanel({...@@ -961,6 +966,7 @@ export function PullRequestDetailPanel({
961 method?: PullRequestMergeMethod,966 method?: PullRequestMergeMethod,
962 updateMethod?: PullRequestUpdateMethod,967 updateMethod?: PullRequestUpdateMethod,
963 ) => {968 ) => {
969 onActed?.(action, "sent");
964 const result = await runAction({970 const result = await runAction({
965 environmentId,971 environmentId,
966 input: {972 input: {
...@@ -988,6 +994,7 @@ export function PullRequestDetailPanel({...@@ -988,6 +994,7 @@ export function PullRequestDetailPanel({
988 title: ACTION_FAILURE_LABELS[action],994 title: ACTION_FAILURE_LABELS[action],
989 description: readableFailure(failure, hint),995 description: readableFailure(failure, hint),
990 });996 });
997 onActed?.(action, "failed");
991 return false;998 return false;
992 }999 }
993 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });1000 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
...@@ -1001,7 +1008,7 @@ export function PullRequestDetailPanel({...@@ -1001,7 +1008,7 @@ export function PullRequestDetailPanel({
1001 } else {1008 } else {
1002 refreshDetail();1009 refreshDetail();
1003 }1010 }
1004 onActed?.();1011 onActed?.(action, "done");
1005 return true;1012 return true;
1006 };1013 };
10071014
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+94
...@@ -32,6 +32,10 @@ import {...@@ -32,6 +32,10 @@ import {
32 resolveQueryEnvironmentIds,32 resolveQueryEnvironmentIds,
33 resolveSelectedEnvironmentId,33 resolveSelectedEnvironmentId,
34 type EnvironmentPullRequestEntry,34 type EnvironmentPullRequestEntry,
35 applyPullRequestOverrides,
36 pullRequestOverrideAfterAction,
37 reusePullRequestEntries,
38 settlePullRequestOverrides,
35} from "./pullRequestList.logic";39} from "./pullRequestList.logic";
36import {40import {
37 pullRequestListPreferences,41 pullRequestListPreferences,
...@@ -1600,3 +1604,93 @@ describe("the priority groups against a paginated feed", () => {...@@ -1600,3 +1604,93 @@ describe("the priority groups against a paginated feed", () => {
1600 ).toEqual([6123]);1604 ).toEqual([6123]);
1601 });1605 });
1602});1606});
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 {...@@ -9,6 +9,7 @@ import {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import type {10import type {
11 ProjectId,11 ProjectId,
12 PullRequestAction,
12 PullRequestActor,13 PullRequestActor,
13 PullRequestDiffStat,14 PullRequestDiffStat,
14 PullRequestInvolvement,15 PullRequestInvolvement,
...@@ -16,6 +17,7 @@ import type {...@@ -16,6 +17,7 @@ import type {
16 PullRequestListCursors,17 PullRequestListCursors,
17 PullRequestListFilters,18 PullRequestListFilters,
18 PullRequestListState,19 PullRequestListState,
20 PullRequestState,
19} from "@t3tools/contracts";21} from "@t3tools/contracts";
2022
21import { toSortableTimestamp } from "../../lib/threadSort";23import { toSortableTimestamp } from "../../lib/threadSort";
...@@ -1140,3 +1142,127 @@ export function withDiffStat<...@@ -1140,3 +1142,127 @@ export function withDiffStat<
1140 const stat = statsByRow.get(pullRequestDiffStatKey(entry));1142 const stat = statsByRow.get(pullRequestDiffStatKey(entry));
1141 return stat === undefined ? entry : { ...entry, ...stat };1143 return stat === undefined ? entry : { ...entry, ...stat };
1142}1144}
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...@@ -4,6 +4,7 @@ import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contr
4import type {4import type {
5 EnvironmentId,5 EnvironmentId,
6 ProjectId,6 ProjectId,
7 PullRequestAction,
7 PullRequestInvolvement,8 PullRequestInvolvement,
8 PullRequestListCursors,9 PullRequestListCursors,
9 PullRequestListFilters,10 PullRequestListFilters,
...@@ -77,6 +78,11 @@ import {...@@ -77,6 +78,11 @@ import {
77 type PullRequestStatsPolicy,78 type PullRequestStatsPolicy,
78 type PullRequestStatsScope,79 type PullRequestStatsScope,
79 type PullRequestPartitionsSnapshot,80 type PullRequestPartitionsSnapshot,
81 applyPullRequestOverrides,
82 type PullRequestListOverride,
83 pullRequestOverrideAfterAction,
84 reusePullRequestEntries,
85 settlePullRequestOverrides,
80} from "../components/pullRequest/pullRequestList.logic";86} from "../components/pullRequest/pullRequestList.logic";
81import {87import {
82 pullRequestListPreferences,88 pullRequestListPreferences,
...@@ -914,6 +920,36 @@ function PullRequestsRouteView() {...@@ -914,6 +920,36 @@ function PullRequestsRouteView() {
914 key: string;920 key: string;
915 entries: ReadonlyArray<EnvironmentPullRequestEntry>;921 entries: ReadonlyArray<EnvironmentPullRequestEntry>;
916 } | null>(null);922 } | 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>());
917 // A reload recreates the registry the queries live in, so with nothing held the page would953 // A reload recreates the registry the queries live in, so with nothing held the page would
918 // cold-start into skeletons even though almost every row is unchanged. The last answer for954 // cold-start into skeletons even though almost every row is unchanged. The last answer for
919 // this set of environments is kept across reloads and hydrated here as the carried rows: they955 // this set of environments is kept across reloads and hydrated here as the carried rows: they
...@@ -1077,8 +1113,20 @@ function PullRequestsRouteView() {...@@ -1077,8 +1113,20 @@ function PullRequestsRouteView() {
1077 // since the last read at the bottom of the page — below rows a week older — where "the1113 // since the last read at the bottom of the page — below rows a week older — where "the
1078 // latest" is exactly what a refresh was for. The host answers in the order the page1114 // latest" is exactly what a refresh was for. The host answers in the order the page
1079 // reads, so its order stands; a row that moved was updated, and moving is the news.1115 // 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 };
1081 });1124 });
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 );
1082 }, [1130 }, [
1083 answered,1131 answered,
1084 filterKey,1132 filterKey,
...@@ -1455,10 +1503,34 @@ function PullRequestsRouteView() {...@@ -1455,10 +1503,34 @@ function PullRequestsRouteView() {
1455 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));1503 setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats));
1456 }, [statsQuery.stats]);1504 }, [statsQuery.stats]);
1457 const displayGroups = useMemo(() => {1505 const displayGroups = useMemo(() => {
1458 const enriched = groups.map((group) => ({1506 // The reader's pending answers go on here, after grouping: the authored and reviewing
1459 ...group,1507 // groups are read separately from the feed, and a row closed a moment ago has to leave
1460 entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),1508 // whichever group it was in.
1461 }));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 });
1462 // Searching keeps its relevance order and priority groups unless the reader explicitly asks1534 // Searching keeps its relevance order and priority groups unless the reader explicitly asks
1463 // for another sort. The readiness queue is the default browse order, not a way to bury a1535 // for another sort. The readiness queue is the default browse order, not a way to bury a
1464 // closer text match.1536 // closer text match.
...@@ -1470,7 +1542,29 @@ function PullRequestsRouteView() {...@@ -1470,7 +1542,29 @@ function PullRequestsRouteView() {
1470 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),1542 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
1471 search.involvement,1543 search.involvement,
1472 );1544 );
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 );
1474 const listedPullRequestsBySurface = useMemo(1568 const listedPullRequestsBySurface = useMemo(
1475 () =>1569 () =>
1476 new Map(1570 new Map(
...@@ -1645,7 +1739,7 @@ function PullRequestsRouteView() {...@@ -1645,7 +1739,7 @@ function PullRequestsRouteView() {
1645 // so that case waits with the skeletons rather than answering for the hosts. A search says so1739 // so that case waits with the skeletons rather than answering for the hosts. A search says so
1646 // in its own words and is left to.1740 // in its own words and is left to.
1647 const carriedToNothing =1741 const carriedToNothing =
1648 showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0;1742 showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0;
1649 const listBody = (1743 const listBody = (
1650 <>1744 <>
1651 {!capabilityKnown ? (1745 {!capabilityKnown ? (
...@@ -1657,7 +1751,7 @@ function PullRequestsRouteView() {...@@ -1657,7 +1751,7 @@ function PullRequestsRouteView() {
1657 />1751 />
1658 ) : firstLoad ? (1752 ) : firstLoad ? (
1659 <PullRequestListGhost rows={7} />1753 <PullRequestListGhost rows={7} />
1660 ) : listQuery.error && entries.length === 0 ? (1754 ) : listQuery.error && shownCount === 0 ? (
1661 <PullRequestsUnavailableState1755 <PullRequestsUnavailableState
1662 error={listQuery.error}1756 error={listQuery.error}
1663 refreshing={listQuery.isPending}1757 refreshing={listQuery.isPending}
...@@ -1665,7 +1759,7 @@ function PullRequestsRouteView() {...@@ -1665,7 +1759,7 @@ function PullRequestsRouteView() {
1665 />1759 />
1666 ) : carriedToNothing ? (1760 ) : carriedToNothing ? (
1667 <PullRequestListGhost rows={7} />1761 <PullRequestListGhost rows={7} />
1668 ) : entries.length === 0 ? (1762 ) : shownCount === 0 ? (
1669 <PullRequestListEmptyState1763 <PullRequestListEmptyState
1670 hasProjects={!projectsKnown || projects.length > 0}1764 hasProjects={!projectsKnown || projects.length > 0}
1671 refreshing={refreshing}1765 refreshing={refreshing}
...@@ -1724,7 +1818,7 @@ function PullRequestsRouteView() {...@@ -1724,7 +1818,7 @@ function PullRequestsRouteView() {
1724 </div>1818 </div>
1725 )}1819 )}
17261820
1727 {listQuery.error && entries.length > 0 ? (1821 {listQuery.error && shownCount > 0 ? (
1728 <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">1822 <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">
1729 <span>{listQuery.error} Showing the last pull requests loaded.</span>1823 <span>{listQuery.error} Showing the last pull requests loaded.</span>
1730 <Button size="xs" variant="outline" onClick={() => listQuery.refresh()}>1824 <Button size="xs" variant="outline" onClick={() => listQuery.refresh()}>
...@@ -2067,9 +2161,40 @@ function PullRequestsRouteView() {...@@ -2067,9 +2161,40 @@ function PullRequestsRouteView() {
2067 refreshToken={detailRefreshToken}2161 refreshToken={detailRefreshToken}
2068 // Host actions can change both readiness and diff size, so refresh the counts2162 // Host actions can change both readiness and diff size, so refresh the counts
2069 // alongside the list. The panel already refreshes itself after each action.2163 // alongside the list. The panel already refreshes itself after each action.
2070 onActed={() => {2164 onActed={(action, phase = "done") => {
2071 // Mutations already invalidate the host's affected caches.2165 // An action that only moves a row's state is written onto the row as it is
2072 refreshListAndStats(undefined, panelEnvironmentId);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);
2073 }}2198 }}
2074 />2199 />
2075 </RightPanelTabs>2200 </RightPanelTabs>