From 0aadddf3ebe40151d96c0c3b9868968addb8f821 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 03:27:36 +0000 Subject: [PATCH 01/10] feat(web): answer pull request actions on the row at once Closing, reopening, merging or toggling draft from the detail panel refetched four or five listings and stranded every cached one, so the row sat unchanged for seconds and the list could swap to skeletons and land the reader back at the top. The row now takes the action's outcome the moment it is sent: a closed pull request leaves an open list on the click, and the host's next whole-page answer replaces the note. Only actions that change counts or checks still ask for the reads. A refresh also hands back the row objects it did not change, so memoized rows stop repainting for the one that moved. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestDetailPanel.tsx | 5 +- .../pullRequest/pullRequestList.logic.test.ts | 56 +++++++++++++ .../pullRequest/pullRequestList.logic.ts | 80 +++++++++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 49 +++++++++++- 4 files changed, 184 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f19e41599043..95dc0b8b3e0c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -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; /** @@ -1001,7 +1002,7 @@ export function PullRequestDetailPanel({ } else { refreshDetail(); } - onActed?.(); + onActed?.(action); return true; }; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 9154e38026d2..7c68a0e93d97 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -32,6 +32,9 @@ import { resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, type EnvironmentPullRequestEntry, + applyPullRequestOverrides, + pullRequestOverrideAfterAction, + reusePullRequestEntries, } from "./pullRequestList.logic"; import { pullRequestListPreferences, @@ -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); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index f001e33829c5..e3ffcd708a31 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import type { ProjectId, + PullRequestAction, PullRequestActor, PullRequestDiffStat, PullRequestInvolvement, @@ -16,6 +17,7 @@ import type { PullRequestListCursors, PullRequestListFilters, PullRequestListState, + PullRequestState, } from "@t3tools/contracts"; import { toSortableTimestamp } from "../../lib/threadSort"; @@ -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, + 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( + entries: ReadonlyArray, + overrides: ReadonlyMap, + keyOf: (entry: Entry) => string, + state: PullRequestListState, +): ReadonlyArray { + 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( + previous: ReadonlyArray, + next: ReadonlyArray, + keyOf: (entry: Entry) => string, +): ReadonlyArray { + 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; +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 485fa7718e3e..869daccf96cd 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -4,6 +4,7 @@ import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contr import type { EnvironmentId, ProjectId, + PullRequestAction, PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, @@ -77,6 +78,10 @@ import { type PullRequestStatsPolicy, type PullRequestStatsScope, type PullRequestPartitionsSnapshot, + applyPullRequestOverrides, + type PullRequestListOverride, + pullRequestOverrideAfterAction, + reusePullRequestEntries, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, @@ -914,6 +919,18 @@ function PullRequestsRouteView() { key: string; entries: ReadonlyArray; } | 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>( + () => 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 @@ -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())); }, [ answered, filterKey, @@ -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. @@ -1245,8 +1276,10 @@ function PullRequestsRouteView() { localFilters, listData, ordered, + overrides, querySettled, search.involvement, + search.state, searchingHosts, showingCarried, typedParsed.text, @@ -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( + pullRequestListEntryId(renderedPullRequestSurface), + ); + if (action !== undefined && acted !== undefined && overrideEntry(acted, action)) { + return; + } refreshListAndStats(undefined, panelEnvironmentId); }} /> From d729f8c7a832572e23225e0d00aa6f4ba92c296e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:15:20 +0000 Subject: [PATCH 02/10] fix(web): write the row's answer as the action is sent, and clear it only when the host agrees The override is written the moment the detail panel sends a state action and taken back if the host refuses, so the row answers on the click rather than after the round trip. It is cleared by a whole-page answer that agrees with it, not by any read that happens to land after it. The acted row is looked up among every held row, so a pull request reopened from an open list is found even though it left the screen. A refresh that only reordered rows no longer hands back the old order. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestDetailPanel.tsx | 12 +++-- .../pullRequest/pullRequestList.logic.test.ts | 27 +++++++++++ .../pullRequest/pullRequestList.logic.ts | 33 ++++++++++++- apps/web/src/routes/_chat.pull-requests.tsx | 48 +++++++++++++++---- 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 95dc0b8b3e0c..a02cb52173bd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -458,8 +458,12 @@ 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. */ - /** The action that just succeeded, or nothing for one the caller cannot name. */ - onActed?: (action?: PullRequestAction) => void; + /** + * Each host action as it goes: "sent" the moment it leaves, so a list can answer before the + * host does; "done" or "failed" when the host has spoken. Undefined for one the caller cannot + * name, which is only ever "done". + */ + onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; /** @@ -962,6 +966,7 @@ export function PullRequestDetailPanel({ method?: PullRequestMergeMethod, updateMethod?: PullRequestUpdateMethod, ) => { + onActed?.(action, "sent"); const result = await runAction({ environmentId, input: { @@ -989,6 +994,7 @@ export function PullRequestDetailPanel({ title: ACTION_FAILURE_LABELS[action], description: readableFailure(failure, hint), }); + onActed?.(action, "failed"); return false; } toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); @@ -1002,7 +1008,7 @@ export function PullRequestDetailPanel({ } else { refreshDetail(); } - onActed?.(action); + onActed?.(action, "done"); return true; }; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 7c68a0e93d97..0dd2802168c1 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -35,6 +35,7 @@ import { applyPullRequestOverrides, pullRequestOverrideAfterAction, reusePullRequestEntries, + settlePullRequestOverrides, } from "./pullRequestList.logic"; import { pullRequestListPreferences, @@ -1656,3 +1657,29 @@ describe("pull request list overrides", () => { ).toBe(previous); }); }); + +describe("pull request list override settlement", () => { + const entry = (number: number, state: "open" | "closed" | "merged") => + ({ number, state, isDraft: false, labels: [] }) as unknown as PullRequestListEntry; + const key = (row: { number: number }) => `#${row.number}`; + + it("keeps an override until an answer agrees with it", () => { + const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z" }; + const overrides = new Map([["#1", closed]]); + // A read from before the action still says open: the override stands. + expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, "open")).toBe(overrides); + // The row is gone from an open list: that is the host agreeing. + expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, "open").size).toBe(0); + // Absent from an "all" list says nothing, so the override stays. + expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, "all").size).toBe(1); + // Present as closed: confirmed. + expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, "all").size).toBe(0); + }); + + it("does not hand back the old order when only the order changed", () => { + const previous = [entry(1, "open"), entry(2, "open")]; + const swapped = reusePullRequestEntries(previous, [entry(2, "open"), entry(1, "open")], key); + expect(swapped).not.toBe(previous); + expect(swapped.map((row) => row.number)).toEqual([2, 1]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index e3ffcd708a31..2b5ed3758cdb 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1218,5 +1218,36 @@ export function reusePullRequestEntries( } return entry; }); - return reused === next.length && previous.length === next.length ? previous : out; + return reused === next.length && + previous.length === next.length && + previous.every((entry, index) => keyOf(entry) === keyOf(next[index]!)) + ? previous + : out; +} + +/** + * The overrides a whole-page answer has confirmed, dropped; the rest kept. A read that started + * before the action can land after it and still say the old thing, so an override is not + * cleared because an answer arrived but because the answer agrees: the row is there in the + * state the override said, or it is gone from a list whose state filter no longer holds it. + */ +export function settlePullRequestOverrides( + overrides: ReadonlyMap, + answered: ReadonlyArray, + keyOf: (entry: Entry) => string, + state: PullRequestListState, +): ReadonlyMap { + if (overrides.size === 0) return overrides; + const byKey = new Map(answered.map((entry) => [keyOf(entry), entry])); + const kept = new Map(); + for (const [key, override] of overrides) { + const row = byKey.get(key); + const confirmed = + row === undefined + ? state !== "all" && override.state !== state + : row.state === override.state && + (override.isDraft === undefined || row.isDraft === override.isDraft); + if (!confirmed) kept.set(key, override); + } + return kept.size === overrides.size ? overrides : kept; } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 869daccf96cd..b22597a4f7cf 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -82,6 +82,7 @@ import { type PullRequestListOverride, pullRequestOverrideAfterAction, reusePullRequestEntries, + settlePullRequestOverrides, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, @@ -1103,9 +1104,13 @@ function PullRequestsRouteView() { ), }; }); - // 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())); + // The host's word outranks the reader's, once it has actually said it: an override is + // cleared by an answer that agrees with it, not by any answer that happens to land. + setOverrides((current) => + settlePullRequestOverrides(current, answered.entries, pullRequestEntryKey, search.state), + ); }, [ + search.state, answered, filterKey, sentCursors, @@ -1504,6 +1509,15 @@ function PullRequestsRouteView() { search.involvement, ); }, [groups, search.involvement, sort, statsByRow, typedParsed.text]); + const heldPullRequestsBySurface = useMemo( + () => + new Map( + (ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? [])).map( + (entry) => [pullRequestListEntryId(entry), entry] as const, + ), + ), + [filterKey, listData, ordered], + ); const listedPullRequestsBySurface = useMemo( () => new Map( @@ -2100,17 +2114,33 @@ 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={(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( + onActed={(action, phase = "done") => { + // An action that only moves a row's state is written onto the row as it is + // sent, and taken back if the host refuses; the host invalidates its caches, + // so the next scheduled read confirms it. The rest change what the counts and + // checks say, and those need the reads once they are done. + // From every row held, not the ones on screen: a pull request closed from an + // open list has left the screen, and reopening it has to find it anyway. + const acted = heldPullRequestsBySurface.get( pullRequestListEntryId(renderedPullRequestSurface), ); - if (action !== undefined && acted !== undefined && overrideEntry(acted, action)) { + const stateOnly = + action !== undefined && + acted !== undefined && + pullRequestOverrideAfterAction(acted, action, "") !== null; + if (stateOnly) { + if (phase === "sent") overrideEntry(acted, action); + if (phase === "failed") { + const key = pullRequestEntryKey(acted); + setOverrides((current) => { + const next = new Map(current); + next.delete(key); + return next; + }); + } return; } - refreshListAndStats(undefined, panelEnvironmentId); + if (phase === "done") refreshListAndStats(undefined, panelEnvironmentId); }} /> From b14d26393602102cf18b61009ac7cc384d662bd6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:39:58 +0000 Subject: [PATCH 03/10] fix(web): write the row's answer after grouping, where the authored rows come from The authored and reviewing groups are read separately from the feed, so an override written onto the feed's rows left the same pull request standing in its group. The overrides now go on after grouping, and the acted row is looked up across every group. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/routes/_chat.pull-requests.tsx | 27 +++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b22597a4f7cf..dc5bc0d09e43 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1241,12 +1241,7 @@ function PullRequestsRouteView() { ); const entries = useMemo(() => { - const known = applyPullRequestOverrides( - ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), - overrides, - pullRequestEntryKey, - search.state, - ); + const known = ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []); 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. @@ -1281,10 +1276,8 @@ function PullRequestsRouteView() { localFilters, listData, ordered, - overrides, querySettled, search.involvement, - search.state, searchingHosts, showingCarried, typedParsed.text, @@ -1493,9 +1486,17 @@ function PullRequestsRouteView() { setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [statsQuery.stats]); const displayGroups = useMemo(() => { + // The reader's pending answers go on here, after grouping: the authored and reviewing + // groups are read separately from the feed, and a row closed a moment ago has to leave + // whichever group it was in. const enriched = groups.map((group) => ({ ...group, - entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), + entries: applyPullRequestOverrides( + group.entries.map((entry) => withDiffStat(entry, statsByRow)), + overrides, + pullRequestEntryKey, + search.state, + ), })); // 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 @@ -1508,15 +1509,15 @@ function PullRequestsRouteView() { entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), search.involvement, ); - }, [groups, search.involvement, sort, statsByRow, typedParsed.text]); + }, [groups, overrides, search.involvement, search.state, sort, statsByRow, typedParsed.text]); const heldPullRequestsBySurface = useMemo( () => new Map( - (ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? [])).map( - (entry) => [pullRequestListEntryId(entry), entry] as const, + groups.flatMap((group) => + group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const), ), ), - [filterKey, listData, ordered], + [groups], ); const listedPullRequestsBySurface = useMemo( () => From 1105b3e4db6437800c532d6c664dabe7f1d29037 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 03:52:49 +0000 Subject: [PATCH 04/10] feat(web): speed mode on the pull requests page while Shift is held Hold Shift over the list and every row wears its own buttons: Close and Merge on an open pull request, Reopen on a closed one. Close and reopen go at once, with the row answering before the host does; merge always asks, with the strategies the repository allows, because it is the one that cannot be taken back. A Shift held while typing in a field is a capital letter, not a mode. Co-Authored-By: Claude Fable 5.1 --- .../components/pullRequest/PullRequestRow.tsx | 271 +++++++++--------- .../pullRequest/PullRequestSpeedActions.tsx | 138 +++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 98 ++++++- 3 files changed, 372 insertions(+), 135 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index e32decde136c..682cd2e17fc1 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -7,6 +7,7 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; +import { PullRequestSpeedActions, type PullRequestSpeedAction } from "./PullRequestSpeedActions"; import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { @@ -75,6 +76,8 @@ function PullRequestRowImpl({ statsKey, statsRef, onSelect, + speed = false, + onSpeedAction, }: { entry: EnvironmentPullRequestEntry; selected: boolean; @@ -92,142 +95,152 @@ function PullRequestRowImpl({ statsKey?: string; statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; + /** Shift is held: the row wears its own close, reopen and merge buttons. */ + speed?: boolean; + onSpeedAction?: (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); + // The row is a button, and the speed buttons cannot nest in one, so they ride a wrapper + // beside it and over its right end. return ( - + /> + ) : null} + + + } + metaClassName="@container/pr-row-meta" + meta={ + <> + {matchedElsewhere ? ( + + + } + > + matched in the description + + + matched in the description + + + Matched in the description + + ) : null} + {showProvider ? ( + + }> + + + {providerName} + + ) : null} + + {showProjectTitle ? {entry.repository} : null} + {environmentLabel ? ( + {environmentLabel} + ) : null} + {entry.labels.length > 0 ? : null} + + } + updatedAt={entry.updatedAt} + /> + + {speed && onSpeedAction ? ( + + ) : null} + ); } diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx new file mode 100644 index 000000000000..03113a099c5b --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -0,0 +1,138 @@ +import type { PullRequestAction, PullRequestMergeMethod } from "@t3tools/contracts"; +import type { MouseEvent } from "react"; + +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { PULL_REQUEST_MERGE_METHOD_LABELS } from "./pullRequestDetail.logic"; +import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; + +export type PullRequestSpeedAction = Extract; + +/** + * The actions a row offers while Shift is held: the ones a reader clears a list with, right on + * the row, without opening it. Close and reopen go at once; merge asks, always, because it is + * the one that cannot be taken back. They sit over the right end of the second line, where the + * linked panel keeps its row menu, so the row keeps its width and nothing else moves. + */ +export function PullRequestSpeedActions({ + entry, + onAct, +}: { + entry: EnvironmentPullRequestEntry; + onAct: (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => void; +}) { + if (entry.state === "merged") return null; + const act = (action: PullRequestSpeedAction) => (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onAct(entry, action); + }; + return ( + + + {entry.state === "open" ? ( + <> + {entry.isDraft ? null : ( + + )} + + + ) : ( + + )} + + ); +} + +/** + * The one confirmation speed mode keeps. The strategies come from the detail read, since the + * row does not carry what the repository allows; until it answers the buttons wait. + */ +export function PullRequestSpeedMergeDialog({ + target, + onClose, + onConfirm, +}: { + target: EnvironmentPullRequestEntry | null; + onClose: () => void; + onConfirm: (entry: EnvironmentPullRequestEntry, method: PullRequestMergeMethod) => void; +}) { + return ( + (open ? undefined : onClose())}> + + {target ? : null} + + + ); +} + +function SpeedMergeBody({ + target, + onConfirm, +}: { + target: EnvironmentPullRequestEntry; + onConfirm: (entry: EnvironmentPullRequestEntry, method: PullRequestMergeMethod) => void; +}) { + const detailQuery = useEnvironmentQuery( + pullRequestEnvironment.detail({ + environmentId: target.environmentId, + input: { + projectId: target.projectId, + repository: target.repository, + number: target.number, + host: target.host, + }, + }), + ); + const detail = detailQuery.data; + const methods = detail + ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) + : []; + return ( + <> + + Merge #{target.number}? + + {target.title} + {detail && methods.length === 0 ? " This repository allows no merge strategy here." : ""} + + + + }>Cancel + {detail ? ( + methods.map((method) => ( + + )) + ) : ( + + )} + + + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index dc5bc0d09e43..803d0f4462db 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -6,6 +6,7 @@ import type { ProjectId, PullRequestAction, PullRequestInvolvement, + PullRequestMergeMethod, PullRequestListCursors, PullRequestListFilters, PullRequestListInput, @@ -153,6 +154,13 @@ import { type EnvironmentQueryTarget, } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { + PullRequestSpeedMergeDialog, + type PullRequestSpeedAction, +} from "../components/pullRequest/PullRequestSpeedActions"; +import { readableFailure } from "../components/pullRequest/pullRequestDetail.logic"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { cn } from "~/lib/utils"; import { Separator } from "~/components/ui/separator"; import { primaryServerKeybindingsAtom } from "~/state/server"; @@ -926,12 +934,80 @@ function PullRequestsRouteView() { const [overrides, setOverrides] = useState>( () => 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; - }; + const overrideEntry = useCallback( + (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; + }, + [], + ); + // Speed mode: Shift held over the list puts close, reopen and merge on the rows themselves. + // A key held while typing in a field is a capital letter, not a mode. + const modifiers = useShortcutModifierState(); + const speed = + modifiers.shiftKey && + !( + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement + ); + const runRowAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const [speedMergeTarget, setSpeedMergeTarget] = useState( + null, + ); + const speedAct = useCallback( + async ( + entry: EnvironmentPullRequestEntry, + action: PullRequestSpeedAction, + mergeMethod?: PullRequestMergeMethod, + ) => { + if (action === "merge" && mergeMethod === undefined) { + setSpeedMergeTarget(entry); + return; + } + const key = pullRequestEntryKey(entry); + overrideEntry(entry, action); + const result = await runRowAction({ + environmentId: entry.environmentId, + input: { + projectId: entry.projectId, + repository: entry.repository, + number: entry.number, + host: entry.host, + action, + ...(mergeMethod ? { mergeMethod } : {}), + }, + }); + if (result._tag === "Failure") { + // The row said what was asked; the host said no, so the row takes it back. + setOverrides((current) => { + const next = new Map(current); + next.delete(key); + return next; + }); + toastManager.add({ + type: "error", + title: `Could not ${action} #${entry.number}`, + description: readableFailure( + squashAtomCommandFailure(result), + "Check your access on the host.", + ), + }); + return; + } + toastManager.add({ + type: "success", + title: `#${entry.number} ${action === "merge" ? "merged" : action === "close" ? "closed" : "reopened"}`, + }); + }, + [overrideEntry, runRowAction], + ); + const onSpeedAction = useCallback( + (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => + void speedAct(entry, action), + [speedAct], + ); // 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 @@ -1764,6 +1840,8 @@ function PullRequestsRouteView() { selected.number === entry.number } onSelect={selectEntry} + speed={speed} + onSpeedAction={onSpeedAction} /> ); })} @@ -1772,6 +1850,14 @@ function PullRequestsRouteView() { )} + setSpeedMergeTarget(null)} + onConfirm={(entry, method) => { + setSpeedMergeTarget(null); + void speedAct(entry, "merge", method); + }} + /> {listQuery.error && entries.length > 0 ? (
{listQuery.error} Showing the last pull requests loaded. From d4a3773e37e566801e6fe20bf4b1f2115baac9db Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 05:18:12 +0000 Subject: [PATCH 05/10] fix(web): one speed action per row at a time, and a note only its own failure takes back A row with a speed action still travelling offers no second one, so two cannot land out of order. Each override carries the token of the write that made it, and a failure takes back only its own. A stacked pull request offers no lone merge from the row. An override is no longer confirmed by a row's absence, since the authored and reviewing groups are read apart from the feed and a page is only a page; a row present in another state is taken for a stale read for a minute and for the host's news after that. A finished merge still reads the list again, for a host that only queued it. Co-Authored-By: Claude Fable 5.1 --- .../components/pullRequest/PullRequestRow.tsx | 5 +- .../pullRequest/PullRequestSpeedActions.tsx | 12 +++- .../pullRequest/pullRequestList.logic.test.ts | 41 +++++++---- .../pullRequest/pullRequestList.logic.ts | 49 ++++++++----- apps/web/src/routes/_chat.pull-requests.tsx | 70 +++++++++++++------ 5 files changed, 119 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 682cd2e17fc1..f118ac4c6072 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -77,6 +77,7 @@ function PullRequestRowImpl({ statsRef, onSelect, speed = false, + speedPending = false, onSpeedAction, }: { entry: EnvironmentPullRequestEntry; @@ -97,6 +98,8 @@ function PullRequestRowImpl({ onSelect: (entry: PullRequestRowTarget) => void; /** Shift is held: the row wears its own close, reopen and merge buttons. */ speed?: boolean; + /** One of this row's speed actions is still with the host. */ + speedPending?: boolean; onSpeedAction?: (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); @@ -238,7 +241,7 @@ function PullRequestRowImpl({ /> {speed && onSpeedAction ? ( - + ) : null}
); diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index 03113a099c5b..e85e65593164 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -27,9 +27,12 @@ export type PullRequestSpeedAction = Extract void; }) { if (entry.state === "merged") return null; @@ -43,8 +46,10 @@ export function PullRequestSpeedActions({ {entry.state === "open" ? ( <> - {entry.isDraft ? null : ( - )} @@ -52,13 +57,14 @@ export function PullRequestSpeedActions({ size="xs" variant="outline" className="text-destructive hover:text-destructive" + disabled={pending} onClick={act("close")} > Close ) : ( - )} diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 0dd2802168c1..357ea20ad76d 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1619,21 +1619,25 @@ describe("pull request list overrides", () => { 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({ + const now = new Date("2026-07-02T00:00:00Z"); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", now, 7)).toEqual({ state: "closed", - updatedAt: at, + updatedAt: "2026-07-02T00:00:00.000Z", + token: 7, + at: now.getTime(), }); - 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(); + expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", now, 1)?.state).toBe( + "open", + ); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", now, 1)?.state).toBe("merged"); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", now, 1)?.isDraft).toBe(true); + expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", now, 1)).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" }], + ["#1", { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at: 0 }], ]); expect( applyPullRequestOverrides(rows, overrides, key, "open").map((row) => row.number), @@ -1664,16 +1668,23 @@ describe("pull request list override settlement", () => { const key = (row: { number: number }) => `#${row.number}`; it("keeps an override until an answer agrees with it", () => { - const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z" }; + const at = 1_000_000; + const closed = { state: "closed" as const, updatedAt: "2026-07-03T00:00:00Z", token: 1, at }; const overrides = new Map([["#1", closed]]); // A read from before the action still says open: the override stands. - expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, "open")).toBe(overrides); - // The row is gone from an open list: that is the host agreeing. - expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, "open").size).toBe(0); - // Absent from an "all" list says nothing, so the override stays. - expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, "all").size).toBe(1); + expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 5_000)).toBe( + overrides, + ); + // Absent from the answer says nothing: the row may live in another group or page. + expect(settlePullRequestOverrides(overrides, [entry(2, "open")], key, at + 5_000).size).toBe(1); // Present as closed: confirmed. - expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, "all").size).toBe(0); + expect(settlePullRequestOverrides(overrides, [entry(1, "closed")], key, at + 5_000).size).toBe( + 0, + ); + // Present as open a good while later: the host's news, which outranks the note. + expect(settlePullRequestOverrides(overrides, [entry(1, "open")], key, at + 90_000).size).toBe( + 0, + ); }); it("does not hand back the old order when only the order changed", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 2b5ed3758cdb..35b46ea7290a 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1153,24 +1153,30 @@ export interface PullRequestListOverride { readonly state: PullRequestState; readonly isDraft?: boolean; readonly updatedAt: string; + /** Which action wrote it, so a failure takes back its own note and not a later one's. */ + readonly token: number; + /** When it was written, in the reader's clock. */ + readonly at: number; } export function pullRequestOverrideAfterAction( entry: Pick, action: PullRequestAction, - at: string, + now: Date, + token: number, ): PullRequestListOverride | null { + const stamp = { updatedAt: now.toISOString(), token, at: now.getTime() }; switch (action) { case "close": - return { state: "closed", updatedAt: at }; + return { state: "closed", ...stamp }; case "reopen": - return { state: "open", updatedAt: at }; + return { state: "open", ...stamp }; case "merge": - return { state: "merged", updatedAt: at }; + return { state: "merged", ...stamp }; case "draft": - return { state: entry.state, isDraft: true, updatedAt: at }; + return { state: entry.state, isDraft: true, ...stamp }; case "ready": - return { state: entry.state, isDraft: false, updatedAt: at }; + return { state: entry.state, isDraft: false, ...stamp }; default: return null; } @@ -1225,29 +1231,38 @@ export function reusePullRequestEntries( : out; } +/** How long a read that disagrees is taken for a stale one rather than for news. */ +export const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000; + /** - * The overrides a whole-page answer has confirmed, dropped; the rest kept. A read that started - * before the action can land after it and still say the old thing, so an override is not - * cleared because an answer arrived but because the answer agrees: the row is there in the - * state the override said, or it is gone from a list whose state filter no longer holds it. + * The overrides an answer has confirmed, dropped; the rest kept. A read that started before + * the action can land after it and still say the old thing, so an override is not cleared + * because an answer arrived but because the answer agrees: the row is there in the state the + * override said. A row that is absent says nothing — the authored and reviewing groups are read + * apart from the feed, and a page is only a page — so absence never confirms. A row present in + * another state is taken for a stale read for a minute, and for the host's news after that, + * which is how a pull request reopened elsewhere comes back. */ export function settlePullRequestOverrides( overrides: ReadonlyMap, answered: ReadonlyArray, keyOf: (entry: Entry) => string, - state: PullRequestListState, + now: number, ): ReadonlyMap { if (overrides.size === 0) return overrides; const byKey = new Map(answered.map((entry) => [keyOf(entry), entry])); const kept = new Map(); for (const [key, override] of overrides) { const row = byKey.get(key); - const confirmed = - row === undefined - ? state !== "all" && override.state !== state - : row.state === override.state && - (override.isDraft === undefined || row.isDraft === override.isDraft); - if (!confirmed) kept.set(key, override); + if (row === undefined) { + kept.set(key, override); + continue; + } + const agrees = + row.state === override.state && + (override.isDraft === undefined || row.isDraft === override.isDraft); + const outranked = now - override.at > PULL_REQUEST_OVERRIDE_TRUST_MS; + if (!agrees && !outranked) kept.set(key, override); } return kept.size === overrides.size ? overrides : kept; } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 803d0f4462db..e863570f90d8 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -934,15 +934,28 @@ function PullRequestsRouteView() { const [overrides, setOverrides] = useState>( () => new Map(), ); + const overrideToken = useRef(0); + /** Writes the action's outcome onto the row; the token names this write for a later rollback. */ const overrideEntry = useCallback( - (entry: EnvironmentPullRequestEntry, action: PullRequestAction) => { - const override = pullRequestOverrideAfterAction(entry, action, new Date().toISOString()); - if (override === null) return false; + (entry: EnvironmentPullRequestEntry, action: PullRequestAction): number | null => { + const token = ++overrideToken.current; + const override = pullRequestOverrideAfterAction(entry, action, new Date(), token); + if (override === null) return null; setOverrides((current) => new Map(current).set(pullRequestEntryKey(entry), override)); - return true; + return token; }, [], ); + /** A rollback for one write only: a later action's note over the same row is left alone. */ + const revertOverride = useCallback((key: string, token: number | null) => { + if (token === null) return; + setOverrides((current) => { + if (current.get(key)?.token !== token) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + }, []); // Speed mode: Shift held over the list puts close, reopen and merge on the rows themselves. // A key held while typing in a field is a capital letter, not a mode. const modifiers = useShortcutModifierState(); @@ -956,6 +969,12 @@ function PullRequestsRouteView() { const [speedMergeTarget, setSpeedMergeTarget] = useState( null, ); + const speedPendingRef = useRef>(new Set()); + /** The detail panel's own writes, by row, so its failure takes back its own note. */ + const detailOverrideTokens = useRef(new Map()); + // Rows with a speed action still travelling: no second one for the same row until the + // host answers the first, so two cannot land out of order. + const [speedPending, setSpeedPending] = useState>(() => new Set()); const speedAct = useCallback( async ( entry: EnvironmentPullRequestEntry, @@ -967,7 +986,10 @@ function PullRequestsRouteView() { return; } const key = pullRequestEntryKey(entry); - overrideEntry(entry, action); + if (speedPendingRef.current.has(key)) return; + speedPendingRef.current = new Set(speedPendingRef.current).add(key); + setSpeedPending(speedPendingRef.current); + const token = overrideEntry(entry, action); const result = await runRowAction({ environmentId: entry.environmentId, input: { @@ -979,13 +1001,14 @@ function PullRequestsRouteView() { ...(mergeMethod ? { mergeMethod } : {}), }, }); + speedPendingRef.current = new Set( + [...speedPendingRef.current].filter((pending) => pending !== key), + ); + setSpeedPending(speedPendingRef.current); if (result._tag === "Failure") { - // The row said what was asked; the host said no, so the row takes it back. - setOverrides((current) => { - const next = new Map(current); - next.delete(key); - return next; - }); + // The row said what was asked; the host said no, so the row takes it back, unless a + // later action has already written something newer over it. + revertOverride(key, token); toastManager.add({ type: "error", title: `Could not ${action} #${entry.number}`, @@ -1001,7 +1024,7 @@ function PullRequestsRouteView() { title: `#${entry.number} ${action === "merge" ? "merged" : action === "close" ? "closed" : "reopened"}`, }); }, - [overrideEntry, runRowAction], + [overrideEntry, revertOverride, runRowAction], ); const onSpeedAction = useCallback( (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => @@ -1183,10 +1206,9 @@ function PullRequestsRouteView() { // The host's word outranks the reader's, once it has actually said it: an override is // cleared by an answer that agrees with it, not by any answer that happens to land. setOverrides((current) => - settlePullRequestOverrides(current, answered.entries, pullRequestEntryKey, search.state), + settlePullRequestOverrides(current, answered.entries, pullRequestEntryKey, Date.now()), ); }, [ - search.state, answered, filterKey, sentCursors, @@ -1841,6 +1863,7 @@ function PullRequestsRouteView() { } onSelect={selectEntry} speed={speed} + speedPending={speedPending.has(entryKey)} onSpeedAction={onSpeedAction} /> ); @@ -2214,16 +2237,19 @@ function PullRequestsRouteView() { const stateOnly = action !== undefined && acted !== undefined && - pullRequestOverrideAfterAction(acted, action, "") !== null; + pullRequestOverrideAfterAction(acted, action, new Date(), 0) !== null; if (stateOnly) { - if (phase === "sent") overrideEntry(acted, action); + const key = pullRequestEntryKey(acted); + if (phase === "sent") { + detailOverrideTokens.current.set(key, overrideEntry(acted, action)); + } if (phase === "failed") { - const key = pullRequestEntryKey(acted); - setOverrides((current) => { - const next = new Map(current); - next.delete(key); - return next; - }); + revertOverride(key, detailOverrideTokens.current.get(key) ?? null); + } + // A merge the host only queued leaves the pull request open, and the row + // would say merged; a finished merge reads the list again to settle it. + if (phase === "done" && action === "merge") { + refreshListAndStats(undefined, panelEnvironmentId); } return; } From 9b9857831de4f5aa046e8b107f7601aa77cfec68 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:58:05 +0000 Subject: [PATCH 06/10] fix(web): speed mode offers only what GitHub rows can do, and a merge is written on once done The row does not carry what a host allows, so the speed buttons show on GitHub rows only; the host still has the last word and a refusal takes the note back. A merge is written onto the row once the host has done it rather than when asked, since a host that only queues one leaves the pull request open, and a finished merge reads the list again. The Shift check no longer touches `document` where there is none. Also carries the list fix that re-applies the local filters to overridden rows and counts shown rows for the empty state. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestSpeedActions.tsx | 5 +- .../pullRequest/pullRequestList.logic.ts | 2 +- apps/web/src/routes/_chat.pull-requests.tsx | 68 +++++++++++++++---- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index e85e65593164..a640f9108ac8 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -35,7 +35,10 @@ export function PullRequestSpeedActions({ pending: boolean; onAct: (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => void; }) { - if (entry.state === "merged") return null; + // Only for GitHub rows: the row does not carry what a host allows, and GitHub is the one + // whose close, reopen and merge the page knows. The host still has the last word, and a + // refusal takes the row's note back. + if (entry.state === "merged" || entry.provider !== "github") return null; const act = (action: PullRequestSpeedAction) => (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 35b46ea7290a..f4cb3a31f46f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1232,7 +1232,7 @@ export function reusePullRequestEntries( } /** How long a read that disagrees is taken for a stale one rather than for news. */ -export const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000; +const PULL_REQUEST_OVERRIDE_TRUST_MS = 60_000; /** * The overrides an answer has confirmed, dropped; the rest kept. A read that started before diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index e863570f90d8..b9cf6f2ed157 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -961,6 +961,7 @@ function PullRequestsRouteView() { const modifiers = useShortcutModifierState(); const speed = modifiers.shiftKey && + typeof document !== "undefined" && !( document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement @@ -989,7 +990,10 @@ function PullRequestsRouteView() { if (speedPendingRef.current.has(key)) return; speedPendingRef.current = new Set(speedPendingRef.current).add(key); setSpeedPending(speedPendingRef.current); - const token = overrideEntry(entry, action); + // A merge is written onto the row once the host has done it, not when it is asked: a + // host that only queues the merge leaves the pull request open, and the row must not + // say merged ahead of it. + const token = action === "merge" ? null : overrideEntry(entry, action); const result = await runRowAction({ environmentId: entry.environmentId, input: { @@ -1023,8 +1027,12 @@ function PullRequestsRouteView() { type: "success", title: `#${entry.number} ${action === "merge" ? "merged" : action === "close" ? "closed" : "reopened"}`, }); + if (action === "merge") { + overrideEntry(entry, action); + refreshListAndStats(undefined, entry.environmentId); + } }, - [overrideEntry, revertOverride, runRowAction], + [overrideEntry, refreshListAndStats, revertOverride, runRowAction], ); const onSpeedAction = useCallback( (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => @@ -1587,15 +1595,31 @@ function PullRequestsRouteView() { // The reader's pending answers go on here, after grouping: the authored and reviewing // groups are read separately from the feed, and a row closed a moment ago has to leave // whichever group it was in. - const enriched = groups.map((group) => ({ - ...group, - entries: applyPullRequestOverrides( + const enriched = groups.map((group) => { + const answered = applyPullRequestOverrides( group.entries.map((entry) => withDiffStat(entry, statsByRow)), overrides, pullRequestEntryKey, search.state, - ), - })); + ); + // A row whose draft flag just changed has to pass the local filters again: a draft + // filter that let it in may not let the new one in. + return { + ...group, + entries: + hasLocalFilters && overrides.size > 0 + ? answered.filter( + (entry) => + !overrides.has(pullRequestEntryKey(entry)) || + matchesPullRequestFilters( + entry, + localFilters, + pullRequestEntryViewer(entry, viewers), + ), + ) + : answered, + }; + }); // 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. @@ -1607,7 +1631,20 @@ function PullRequestsRouteView() { entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), search.involvement, ); - }, [groups, overrides, search.involvement, search.state, sort, statsByRow, typedParsed.text]); + }, [ + groups, + hasLocalFilters, + localFilters, + overrides, + search.involvement, + search.state, + sort, + statsByRow, + typedParsed.text, + viewers, + ]); + /** What is actually on screen once the reader's pending answers are on the rows. */ + const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0); const heldPullRequestsBySurface = useMemo( () => new Map( @@ -1791,7 +1828,7 @@ function PullRequestsRouteView() { // so that case waits with the skeletons rather than answering for the hosts. A search says so // in its own words and is left to. const carriedToNothing = - showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0; + showingCarried && listQuery.isPending && shownCount === 0 && typedQuery.length === 0; const listBody = ( <> {!capabilityKnown ? ( @@ -1803,7 +1840,7 @@ function PullRequestsRouteView() { /> ) : firstLoad ? ( - ) : listQuery.error && entries.length === 0 ? ( + ) : listQuery.error && shownCount === 0 ? ( ) : carriedToNothing ? ( - ) : entries.length === 0 ? ( + ) : shownCount === 0 ? ( 0} refreshing={refreshing} @@ -1881,7 +1918,7 @@ function PullRequestsRouteView() { void speedAct(entry, "merge", method); }} /> - {listQuery.error && entries.length > 0 ? ( + {listQuery.error && shownCount > 0 ? (
{listQuery.error} Showing the last pull requests loaded. )} - From 6e8d72e2a24817479d3e8406df686baaf8f36725 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 17:42:56 +0000 Subject: [PATCH 09/10] feat(web): speed buttons wear their glyphs, and an open row can close with a comment The row buttons in speed mode were text only; they now carry the merge, close and reopen glyphs the detail menu uses. Open rows gain a fourth button that opens a small composer on the row, posts the comment, then closes the pull request. A refused comment takes the row's note back and closes nothing. Co-Authored-By: Claude Fable 5.1 --- .../components/pullRequest/PullRequestRow.tsx | 13 +- .../pullRequest/PullRequestSpeedActions.tsx | 117 +++++++++++++++++- apps/web/src/routes/_chat.pull-requests.tsx | 102 ++++++++++++--- 3 files changed, 212 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index f118ac4c6072..99ea27344aec 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -79,6 +79,7 @@ function PullRequestRowImpl({ speed = false, speedPending = false, onSpeedAction, + onSpeedCloseWithComment, }: { entry: EnvironmentPullRequestEntry; selected: boolean; @@ -101,6 +102,8 @@ function PullRequestRowImpl({ /** One of this row's speed actions is still with the host. */ speedPending?: boolean; onSpeedAction?: (entry: EnvironmentPullRequestEntry, action: PullRequestSpeedAction) => void; + /** A close that carries a comment; resolves to whether the comment landed. */ + onSpeedCloseWithComment?: (entry: EnvironmentPullRequestEntry, body: string) => Promise; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); // The row is a button, and the speed buttons cannot nest in one, so they ride a wrapper @@ -240,8 +243,14 @@ function PullRequestRowImpl({ updatedAt={entry.updatedAt} /> - {speed && onSpeedAction ? ( - + {onSpeedAction && onSpeedCloseWithComment ? ( + ) : null}
); diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index fc342b03d594..f4eacbff3b20 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -1,5 +1,6 @@ import type { PullRequestAction, PullRequestMergeMethod } from "@t3tools/contracts"; -import type { MouseEvent } from "react"; +import { MessageSquareIcon } from "lucide-react"; +import { useRef, useState, type MouseEvent } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -14,7 +15,10 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Textarea } from "../ui/textarea"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "./pullRequestDetail.logic"; +import { PullRequestGlyph } from "./pullRequestIcons"; import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; export type PullRequestSpeedAction = Extract; @@ -27,14 +31,25 @@ export type PullRequestSpeedAction = Extract void; + /** Resolves to whether the comment landed; the composer closes only when it did. */ + onCloseWithComment: (entry: EnvironmentPullRequestEntry, body: string) => Promise; }) { + const [commentOpen, setCommentOpen] = useState(false); + // Typing the comment moves focus into a field and lets Shift go, both of which end speed + // mode for the page; this row keeps its buttons while the composer is open so the anchor + // does not vanish from under it. + if (!shown && !commentOpen) return null; // Only for GitHub rows: the row does not carry what a host allows, and GitHub is the one // whose close, reopen and merge the page knows. The host still has the last word, and a // refusal takes the row's note back. @@ -53,15 +68,25 @@ export function PullRequestSpeedActions({ whether one layer may go alone; the row offers no lone merge for it. */} {entry.isDraft || entry.stack ? null : ( )} + ) : ( )} @@ -69,6 +94,96 @@ export function PullRequestSpeedActions({ ); } +/** + * The one speed action that needs words: a close with a comment, written in a popover on the + * row rather than the detail's composer. The text stays after a refusal so it can be sent again. + */ +function SpeedCloseWithComment({ + entry, + open, + pending, + onOpenChange, + onSubmit, +}: { + entry: EnvironmentPullRequestEntry; + open: boolean; + pending: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (entry: EnvironmentPullRequestEntry, body: string) => Promise; +}) { + const [body, setBody] = useState(""); + const [sending, setSending] = useState(false); + const textareaRef = useRef(null); + const submit = async () => { + const trimmed = body.trim(); + if (trimmed.length === 0 || sending || pending) return; + setSending(true); + const posted = await onSubmit(entry, trimmed); + setSending(false); + if (posted) { + setBody(""); + onOpenChange(false); + } + }; + return ( + + } + // The row under it is a button: a click that reached it would open the detail. + onClick={(event: MouseEvent) => event.stopPropagation()} + > + + Close with comment + + +
+