| author | |
| committer | |
| log | 371b52d9dad76876f84609a4cd0f80eaa757b69c |
| tree | 4df0ba1762f873e965d67f37ba4dcc8edf59c0bd |
| parent | 1de563c1491c7d82563e4553bf5bf689ce6adbb9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 458 | * An action changed this pull request on the host, so a list showing it is now out of date. |
| 459 | 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 | 467 | /** Page-owned detail columns use this to clear the selected pull request. */ |
| 463 | 468 | onClose?: () => void; |
| 464 | 469 | /** |
| ... | ... | @@ -961,6 +966,7 @@ export function PullRequestDetailPanel({ |
| 961 | 966 | method?: PullRequestMergeMethod, |
| 962 | 967 | updateMethod?: PullRequestUpdateMethod, |
| 963 | 968 | ) => { |
| 969 | onActed?.(action, "sent"); | |
| 964 | 970 | const result = await runAction({ |
| 965 | 971 | environmentId, |
| 966 | 972 | input: { |
| ... | ... | @@ -988,6 +994,7 @@ export function PullRequestDetailPanel({ |
| 988 | 994 | title: ACTION_FAILURE_LABELS[action], |
| 989 | 995 | description: readableFailure(failure, hint), |
| 990 | 996 | }); |
| 997 | onActed?.(action, "failed"); | |
| 991 | 998 | return false; |
| 992 | 999 | } |
| 993 | 1000 | toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); |
| ... | ... | @@ -1001,7 +1008,7 @@ export function PullRequestDetailPanel({ |
| 1001 | 1008 | } else { |
| 1002 | 1009 | refreshDetail(); |
| 1003 | 1010 | } |
| 1004 | onActed?.(); | |
| 1011 | onActed?.(action, "done"); | |
| 1005 | 1012 | return true; |
| 1006 | 1013 | }; |
| 1007 | 1014 |
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+94| ... | ... | @@ -32,6 +32,10 @@ import { |
| 32 | 32 | resolveQueryEnvironmentIds, |
| 33 | 33 | resolveSelectedEnvironmentId, |
| 34 | 34 | type EnvironmentPullRequestEntry, |
| 35 | applyPullRequestOverrides, | |
| 36 | pullRequestOverrideAfterAction, | |
| 37 | reusePullRequestEntries, | |
| 38 | settlePullRequestOverrides, | |
| 35 | 39 | } from "./pullRequestList.logic"; |
| 36 | 40 | import { |
| 37 | 41 | pullRequestListPreferences, |
| ... | ... | @@ -1600,3 +1604,93 @@ describe("the priority groups against a paginated feed", () => { |
| 1600 | 1604 | ).toEqual([6123]); |
| 1601 | 1605 | }); |
| 1602 | 1606 | }); |
| 1607 | ||
| 1608 | describe("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 | ||
| 1665 | describe("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 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import type { |
| 11 | 11 | ProjectId, |
| 12 | PullRequestAction, | |
| 12 | 13 | PullRequestActor, |
| 13 | 14 | PullRequestDiffStat, |
| 14 | 15 | PullRequestInvolvement, |
| ... | ... | @@ -16,6 +17,7 @@ import type { |
| 16 | 17 | PullRequestListCursors, |
| 17 | 18 | PullRequestListFilters, |
| 18 | 19 | PullRequestListState, |
| 20 | PullRequestState, | |
| 19 | 21 | } from "@t3tools/contracts"; |
| 20 | 22 | |
| 21 | 23 | import { toSortableTimestamp } from "../../lib/threadSort"; |
| ... | ... | @@ -1140,3 +1142,127 @@ export function withDiffStat< |
| 1140 | 1142 | const stat = statsByRow.get(pullRequestDiffStatKey(entry)); |
| 1141 | 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 | */ | |
| 1152 | export 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 | ||
| 1162 | export 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. */ | |
| 1186 | export 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 | */ | |
| 1211 | export 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. */ | |
| 1235 | const 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 | */ | |
| 1246 | export 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 | 4 | import type { |
| 5 | 5 | EnvironmentId, |
| 6 | 6 | ProjectId, |
| 7 | PullRequestAction, | |
| 7 | 8 | PullRequestInvolvement, |
| 8 | 9 | PullRequestListCursors, |
| 9 | 10 | PullRequestListFilters, |
| ... | ... | @@ -77,6 +78,11 @@ import { |
| 77 | 78 | type PullRequestStatsPolicy, |
| 78 | 79 | type PullRequestStatsScope, |
| 79 | 80 | type PullRequestPartitionsSnapshot, |
| 81 | applyPullRequestOverrides, | |
| 82 | type PullRequestListOverride, | |
| 83 | pullRequestOverrideAfterAction, | |
| 84 | reusePullRequestEntries, | |
| 85 | settlePullRequestOverrides, | |
| 80 | 86 | } from "../components/pullRequest/pullRequestList.logic"; |
| 81 | 87 | import { |
| 82 | 88 | pullRequestListPreferences, |
| ... | ... | @@ -914,6 +920,36 @@ function PullRequestsRouteView() { |
| 914 | 920 | key: string; |
| 915 | 921 | entries: ReadonlyArray<EnvironmentPullRequestEntry>; |
| 916 | 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 | 953 | // A reload recreates the registry the queries live in, so with nothing held the page would |
| 918 | 954 | // cold-start into skeletons even though almost every row is unchanged. The last answer for |
| 919 | 955 | // this set of environments is kept across reloads and hydrated here as the carried rows: they |
| ... | ... | @@ -1077,8 +1113,20 @@ function PullRequestsRouteView() { |
| 1077 | 1113 | // since the last read at the bottom of the page — below rows a week older — where "the |
| 1078 | 1114 | // latest" is exactly what a refresh was for. The host answers in the order the page |
| 1079 | 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 | 1131 | answered, |
| 1084 | 1132 | filterKey, |
| ... | ... | @@ -1455,10 +1503,34 @@ function PullRequestsRouteView() { |
| 1455 | 1503 | setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); |
| 1456 | 1504 | }, [statsQuery.stats]); |
| 1457 | 1505 | 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 | }); | |
| 1462 | 1534 | // Searching keeps its relevance order and priority groups unless the reader explicitly asks |
| 1463 | 1535 | // for another sort. The readiness queue is the default browse order, not a way to bury a |
| 1464 | 1536 | // closer text match. |
| ... | ... | @@ -1470,7 +1542,29 @@ function PullRequestsRouteView() { |
| 1470 | 1542 | entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), |
| 1471 | 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 | 1568 | const listedPullRequestsBySurface = useMemo( |
| 1475 | 1569 | () => |
| 1476 | 1570 | new Map( |
| ... | ... | @@ -1645,7 +1739,7 @@ function PullRequestsRouteView() { |
| 1645 | 1739 | // so that case waits with the skeletons rather than answering for the hosts. A search says so |
| 1646 | 1740 | // in its own words and is left to. |
| 1647 | 1741 | const carriedToNothing = |
| 1648 | showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0; | |
| 1742 | showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0; | |
| 1649 | 1743 | const listBody = ( |
| 1650 | 1744 | <> |
| 1651 | 1745 | {!capabilityKnown ? ( |
| ... | ... | @@ -1657,7 +1751,7 @@ function PullRequestsRouteView() { |
| 1657 | 1751 | /> |
| 1658 | 1752 | ) : firstLoad ? ( |
| 1659 | 1753 | <PullRequestListGhost rows={7} /> |
| 1660 | ) : listQuery.error && entries.length === 0 ? ( | |
| 1754 | ) : listQuery.error && shownCount === 0 ? ( | |
| 1661 | 1755 | <PullRequestsUnavailableState |
| 1662 | 1756 | error={listQuery.error} |
| 1663 | 1757 | refreshing={listQuery.isPending} |
| ... | ... | @@ -1665,7 +1759,7 @@ function PullRequestsRouteView() { |
| 1665 | 1759 | /> |
| 1666 | 1760 | ) : carriedToNothing ? ( |
| 1667 | 1761 | <PullRequestListGhost rows={7} /> |
| 1668 | ) : entries.length === 0 ? ( | |
| 1762 | ) : shownCount === 0 ? ( | |
| 1669 | 1763 | <PullRequestListEmptyState |
| 1670 | 1764 | hasProjects={!projectsKnown || projects.length > 0} |
| 1671 | 1765 | refreshing={refreshing} |
| ... | ... | @@ -1724,7 +1818,7 @@ function PullRequestsRouteView() { |
| 1724 | 1818 | </div> |
| 1725 | 1819 | )} |
| 1726 | 1820 | |
| 1727 | {listQuery.error && entries.length > 0 ? ( | |
| 1821 | {listQuery.error && shownCount > 0 ? ( | |
| 1728 | 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 | 1823 | <span>{listQuery.error} Showing the last pull requests loaded.</span> |
| 1730 | 1824 | <Button size="xs" variant="outline" onClick={() => listQuery.refresh()}> |
| ... | ... | @@ -2067,9 +2161,40 @@ function PullRequestsRouteView() { |
| 2067 | 2161 | refreshToken={detailRefreshToken} |
| 2068 | 2162 | // Host actions can change both readiness and diff size, so refresh the counts |
| 2069 | 2163 | // 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); | |
| 2073 | 2198 | }} |
| 2074 | 2199 | /> |
| 2075 | 2200 | </RightPanelTabs> |