Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,8 @@ export function PullRequestDetailPanel({
* An action changed this pull request on the host, so a list showing it is now out of date.
* Told rather than assumed: only the page knows whether it is showing one.
*/
onActed?: () => void;
/** The action that just succeeded, or nothing for one the caller cannot name. */
onActed?: (action?: PullRequestAction) => void;
/** Page-owned detail columns use this to clear the selected pull request. */
onClose?: () => void;
/**
Expand Down Expand Up @@ -1001,7 +1002,7 @@ export function PullRequestDetailPanel({
} else {
refreshDetail();
}
onActed?.();
onActed?.(action);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
return true;
};

Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
resolveQueryEnvironmentIds,
resolveSelectedEnvironmentId,
type EnvironmentPullRequestEntry,
applyPullRequestOverrides,
pullRequestOverrideAfterAction,
reusePullRequestEntries,
} from "./pullRequestList.logic";
import {
pullRequestListPreferences,
Expand Down Expand Up @@ -1600,3 +1603,56 @@ describe("the priority groups against a paginated feed", () => {
).toEqual([6123]);
});
});

describe("pull request list overrides", () => {
const entry = (number: number, state: "open" | "closed" | "merged") =>
({
host: "github.com",
repository: "pingdotgg/t3code",
number,
state,
isDraft: false,
updatedAt: "2026-07-01T00:00:00Z",
labels: [],
}) as unknown as PullRequestListEntry;
const key = (row: { number: number }) => `#${row.number}`;

it("maps the actions that change a row's state and nothing else", () => {
const at = "2026-07-02T00:00:00Z";
expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", at)).toEqual({
state: "closed",
updatedAt: at,
});
expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", at)?.state).toBe("open");
expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", at)?.state).toBe("merged");
expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", at)?.isDraft).toBe(true);
expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", at)).toBeNull();
});

it("writes the override over the row and drops it from a list whose state it left", () => {
const rows = [entry(1, "open"), entry(2, "open")];
const overrides = new Map([
["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z" }],
]);
expect(
applyPullRequestOverrides(rows, overrides, key, "open").map((row) => row.number),
).toEqual([2]);
const all = applyPullRequestOverrides(rows, overrides, key, "all");
expect(all.map((row) => [row.number, row.state])).toEqual([
[1, "closed"],
[2, "open"],
]);
expect(applyPullRequestOverrides(rows, new Map(), key, "open")).toBe(rows);
});

it("hands back the held object for a row a refresh did not change", () => {
const previous = [entry(1, "open"), entry(2, "open")];
const next = [{ ...entry(1, "open") }, { ...entry(2, "open"), state: "merged" as const }];
const reused = reusePullRequestEntries(previous, next, key);
expect(reused[0]).toBe(previous[0]);
expect(reused[1]).toBe(next[1]);
expect(
reusePullRequestEntries(previous, [{ ...entry(1, "open") }, { ...entry(2, "open") }], key),
).toBe(previous);
});
});
80 changes: 80 additions & 0 deletions apps/web/src/components/pullRequest/pullRequestList.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
} from "@t3tools/contracts";
import type {
ProjectId,
PullRequestAction,
PullRequestActor,
PullRequestDiffStat,
PullRequestInvolvement,
PullRequestLabel,
PullRequestListCursors,
PullRequestListFilters,
PullRequestListState,
PullRequestState,
} from "@t3tools/contracts";

import { toSortableTimestamp } from "../../lib/threadSort";
Expand Down Expand Up @@ -1140,3 +1142,81 @@ export function withDiffStat<
const stat = statsByRow.get(pullRequestDiffStatKey(entry));
return stat === undefined ? entry : { ...entry, ...stat };
}

/**
* What a row should say the moment an action is sent, before any host has answered. The host
* is the record and a later read replaces this, but the reader pressed the button and should
* see the row answer at once: a closed pull request leaves an "open" list on the click, not
* after the reads that follow.
*/
export interface PullRequestListOverride {
readonly state: PullRequestState;
readonly isDraft?: boolean;
readonly updatedAt: string;
}

export function pullRequestOverrideAfterAction(
entry: Pick<PullRequestListEntry, "state" | "isDraft">,
action: PullRequestAction,
at: string,
): PullRequestListOverride | null {
switch (action) {
case "close":
return { state: "closed", updatedAt: at };
case "reopen":
return { state: "open", updatedAt: at };
case "merge":
return { state: "merged", updatedAt: at };
case "draft":
return { state: entry.state, isDraft: true, updatedAt: at };
case "ready":
return { state: entry.state, isDraft: false, updatedAt: at };
default:
return null;
}
}

/** The rows with their pending answers written over them, and the ones the list's state filter no longer holds dropped. */
export function applyPullRequestOverrides<Entry extends PullRequestListEntry>(
entries: ReadonlyArray<Entry>,
overrides: ReadonlyMap<string, PullRequestListOverride>,
keyOf: (entry: Entry) => string,
state: PullRequestListState,
): ReadonlyArray<Entry> {
if (overrides.size === 0) return entries;
const out: Entry[] = [];
for (const entry of entries) {
const override = overrides.get(keyOf(entry));
if (override === undefined) {
out.push(entry);
continue;
}
if (state !== "all" && override.state !== state) continue;
out.push({ ...entry, ...override });
}
return out;
}

/**
* A fresh answer with the rows it did not change handed back as the objects already held, so a
* memoized row whose data is the same does not render again. Every refresh otherwise rebuilds
* every entry, and a hundred rows repaint for the one that moved.
*/
export function reusePullRequestEntries<Entry extends PullRequestListEntry>(
previous: ReadonlyArray<Entry>,
next: ReadonlyArray<Entry>,
keyOf: (entry: Entry) => string,
): ReadonlyArray<Entry> {
if (previous.length === 0) return next;
const held = new Map(previous.map((entry) => [keyOf(entry), entry]));
let reused = 0;
const out = next.map((entry) => {
const before = held.get(keyOf(entry));
if (before !== undefined && JSON.stringify(before) === JSON.stringify(entry)) {
reused += 1;
return before;
}
return entry;
});
return reused === next.length && previous.length === next.length ? previous : out;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}
49 changes: 45 additions & 4 deletions apps/web/src/routes/_chat.pull-requests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contr
import type {
EnvironmentId,
ProjectId,
PullRequestAction,
PullRequestInvolvement,
PullRequestListCursors,
PullRequestListFilters,
Expand Down Expand Up @@ -77,6 +78,10 @@ import {
type PullRequestStatsPolicy,
type PullRequestStatsScope,
type PullRequestPartitionsSnapshot,
applyPullRequestOverrides,
type PullRequestListOverride,
pullRequestOverrideAfterAction,
reusePullRequestEntries,
} from "../components/pullRequest/pullRequestList.logic";
import {
pullRequestListPreferences,
Expand Down Expand Up @@ -914,6 +919,18 @@ function PullRequestsRouteView() {
key: string;
entries: ReadonlyArray<EnvironmentPullRequestEntry>;
} | null>(null);
// What the reader just did to a row, shown before any host confirms it. A closed pull request
// leaves an "open" list on the click; the reads that follow are slow, and until one lands the
// row says what was asked of it. Cleared when a whole-page answer arrives after the action.
const [overrides, setOverrides] = useState<ReadonlyMap<string, PullRequestListOverride>>(
() => new Map(),
);
const overrideEntry = (entry: EnvironmentPullRequestEntry, action: PullRequestAction) => {
const override = pullRequestOverrideAfterAction(entry, action, new Date().toISOString());
if (override === null) return false;
setOverrides((current) => new Map(current).set(pullRequestEntryKey(entry), override));
return true;
};
// A reload recreates the registry the queries live in, so with nothing held the page would
// cold-start into skeletons even though almost every row is unchanged. The last answer for
// this set of environments is kept across reloads and hydrated here as the carried rows: they
Expand Down Expand Up @@ -1077,8 +1094,17 @@ function PullRequestsRouteView() {
// since the last read at the bottom of the page — below rows a week older — where "the
// latest" is exactly what a refresh was for. The host answers in the order the page
// reads, so its order stands; a row that moved was updated, and moving is the news.
return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentParsed.text) };
return {
key: filterKey,
entries: reusePullRequestEntries(
previous.entries,
rankPullRequestMatches(answered.entries, sentParsed.text),
pullRequestEntryKey,
),
};
});
// A whole answer read after the action is the host's word, which outranks the reader's.
setOverrides((current) => (current.size === 0 ? current : new Map()));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}, [
answered,
filterKey,
Expand Down Expand Up @@ -1210,7 +1236,12 @@ function PullRequestsRouteView() {
);

const entries = useMemo(() => {
const known = ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []);
const known = applyPullRequestOverrides(
ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []),
overrides,
pullRequestEntryKey,
search.state,
);
const involvementEntries = filterPullRequestsByInvolvement(known, viewers, search.involvement);
// The hosts search more than the row shows — a body, a review, a commit message — so once
// their answer is in, narrowing it again here would throw away matches the reader asked for.
Expand Down Expand Up @@ -1245,8 +1276,10 @@ function PullRequestsRouteView() {
localFilters,
listData,
ordered,
overrides,
querySettled,
search.involvement,
search.state,
searchingHosts,
showingCarried,
typedParsed.text,
Expand Down Expand Up @@ -2067,8 +2100,16 @@ function PullRequestsRouteView() {
refreshToken={detailRefreshToken}
// Host actions can change both readiness and diff size, so refresh the counts
// alongside the list. The panel already refreshes itself after each action.
onActed={() => {
// Mutations already invalidate the host's affected caches.
onActed={(action) => {
// An action that only moves a row's state is written onto the row at once; the
// host has invalidated its caches, so the next scheduled read confirms it. The
// rest change what the counts and checks say, and those need the reads now.
const acted = listedPullRequestsBySurface.get(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
pullRequestListEntryId(renderedPullRequestSurface),
);
if (action !== undefined && acted !== undefined && overrideEntry(acted, action)) {
return;
}
refreshListAndStats(undefined, panelEnvironmentId);
}}
/>
Expand Down
Loading