authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 17:28:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 17:28:28-04:00
log493fbb58870c912b9cb2ba6c2f1dae938877a59a
treef7e6cffc545b9275c126656c737f99e013da28e8
parentd2b6f3b9296f682c6158b894ab33d98d0c4bfb2b
signature Signed by PGP key B5690EEEBB952194

fix(web): reuse pull request list data while loading (#9467)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

3 files changed, 157 insertions(+), 17 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+13-3
......@@ -4,6 +4,7 @@ import {
44 type EnvironmentId,
55 type PullRequestAction,
66 type PullRequestMergeMethod,
7 type PullRequestListEntry,
78 type PullRequestUpdateMethod,
89 type PullRequestRef,
910 type PullRequestState,
......@@ -447,6 +448,7 @@ export function PullRequestDetailPanel({
447448 environmentId,
448449 threadRef = null,
449450 reference,
451 listEntry = null,
450452 refreshToken: forcedRefreshToken = 0,
451453 onActed,
452454 onClose,
......@@ -463,6 +465,8 @@ export function PullRequestDetailPanel({
463465 */
464466 threadRef?: ScopedThreadRef | null;
465467 reference: PullRequestRef;
468 /** Row fields already loaded by the pull-request list, used while richer detail arrives. */
469 listEntry?: PullRequestListEntry | null;
466470 /**
467471 * Bumped by whatever holds the panel when a reader asks for everything on screen to be read
468472 * again. The panel owns its own reads, so the page cannot refresh them for it — it says when,
......@@ -491,6 +495,12 @@ export function PullRequestDetailPanel({
491495 composerDraftTarget?: ScopedThreadRef | DraftId;
492496}) {
493497 const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;
498 const matchingListEntry =
499 listEntry?.projectId === reference.projectId &&
500 listEntry.repository.toLowerCase() === reference.repository.toLowerCase() &&
501 listEntry.number === reference.number
502 ? listEntry
503 : null;
494504 const [tab, setTab] = useState<DetailTab>("summary");
495505 const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");
496506 const [codeCommitScope, setCodeCommitScope] = useState<{
......@@ -1296,10 +1306,10 @@ export function PullRequestDetailPanel({
12961306 ).length
12971307 : 0;
12981308
1299 // A reopen already has last time's title, author, and counts. Keep them on screen
1300 // and let the live read replace fields — especially the diff counts — in place.
1309 // The list already has the pull request's identity and summary. Keep them on screen
1310 // and let the richer detail read replace the remaining placeholders in place.
13011311 if (detailQuery.isPending && !detail) {
1302 return <PullRequestDetailGhost />;
1312 return <PullRequestDetailGhost seed={matchingListEntry} />;
13031313 }
13041314
13051315 return (
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+127-14
......@@ -7,7 +7,19 @@
77 * both themes) and the single `animate-skeleton` pulse, applied once on the container so any
88 * number of bars costs one opacity animation.
99 */
10import type { PullRequestListEntry } from "@t3tools/contracts";
11import { ArrowLeftIcon } from "lucide-react";
12
1013import { cn } from "~/lib/utils";
14import { formatRelativeTimeLabel } from "~/timestampFormat";
15
16import { pullRequestLabelColor } from "./pullRequestList.logic";
17import {
18 PullRequestActorLabel,
19 PullRequestDiffStat,
20 pullRequestChecksStatePresentation,
21 resolvePullRequestState,
22} from "./pullRequestPresentation";
1123
1224function GhostBar({ className }: { className?: string | undefined }) {
1325 return <div aria-hidden className={cn("h-3 rounded bg-muted-foreground/15", className)} />;
......@@ -60,18 +72,46 @@ export function PullRequestListGhost({
6072 * boundaries in the ghost prevents the loaded pull request from replacing one layout with
6173 * another a moment later.
6274 */
63export function PullRequestDetailGhost() {
75export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) {
76 const statePresentation = seed
77 ? resolvePullRequestState({
78 state: seed.state,
79 isDraft: seed.isDraft,
80 })
81 : null;
82 const checksPresentation = seed?.checksState
83 ? pullRequestChecksStatePresentation(seed.checksState)
84 : null;
85
6486 return (
6587 <div
6688 role="status"
6789 aria-label="Loading pull request"
68 className="motion-safe:animate-skeleton flex h-full min-h-0 flex-col overflow-hidden bg-background"
90 className={cn(
91 "flex h-full min-h-0 flex-col overflow-hidden bg-background",
92 !seed && "motion-safe:animate-skeleton",
93 )}
6994 >
7095 <div className="shrink-0 border-b border-border/60">
7196 <div className="flex h-7 items-center justify-between gap-3 px-4">
7297 <div className="flex min-w-0 flex-1 items-center gap-1.5">
73 <GhostBar className="w-24" />
74 <GhostBar className="w-9" />
98 {seed && statePresentation ? (
99 <>
100 <span className="min-w-0 truncate text-xs font-medium text-muted-foreground">
101 {seed.repository}
102 </span>
103 <span
104 className={cn("shrink-0 text-xs font-medium", statePresentation.toneClassName)}
105 >
106 #{seed.number}
107 </span>
108 </>
109 ) : (
110 <>
111 <GhostBar className="w-24" />
112 <GhostBar className="w-9" />
113 </>
114 )}
75115 </div>
76116 <div className="flex shrink-0 items-center gap-1">
77117 <GhostBar className="h-5 w-16 rounded-md" />
......@@ -80,18 +120,58 @@ export function PullRequestDetailGhost() {
80120 </div>
81121
82122 <div className="px-4 pb-4 pt-1">
83 <GhostBar className="h-5 w-4/5 max-w-md" />
123 {seed ? (
124 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>
125 ) : (
126 <GhostBar className="h-5 w-4/5 max-w-md" />
127 )}
84128 <div className="mt-2 flex items-center gap-1.5">
85 <GhostBar className="size-4 rounded-full" />
86 <GhostBar className="w-24" />
129 {seed ? (
130 <>
131 <PullRequestActorLabel
132 actor={seed.author}
133 className="font-medium"
134 tooltip={false}
135 />
136 <span className="text-xs text-muted-foreground">
137 updated {formatRelativeTimeLabel(seed.updatedAt)}
138 </span>
139 </>
140 ) : (
141 <>
142 <GhostBar className="size-4 rounded-full" />
143 <GhostBar className="w-24" />
144 </>
145 )}
87146 </div>
88147 <div className="mt-4 flex min-w-0 items-center gap-2">
89 <GhostBar className="h-6 w-24 rounded-md" />
90 <GhostBar className="size-3 rounded-full" />
91 <GhostBar className="h-6 w-32 rounded-md" />
148 {seed ? (
149 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
150 <code className="min-w-0 max-w-[40%] shrink-0 truncate">{seed.baseBranch}</code>
151 <ArrowLeftIcon
152 aria-label="receives changes from"
153 className="size-3.5 shrink-0 opacity-60"
154 />
155 <code className="min-w-0 flex-1 truncate">{seed.headBranch}</code>
156 </span>
157 ) : (
158 <>
159 <GhostBar className="h-6 w-24 rounded-md" />
160 <GhostBar className="size-3 rounded-full" />
161 <GhostBar className="h-6 w-32 rounded-md" />
162 </>
163 )}
92164 <div className="ml-auto flex shrink-0 items-center gap-2">
93165 <GhostBar className="w-10" />
94 <GhostBar className="w-20" />
166 {seed ? (
167 <PullRequestDiffStat
168 additions={seed.additions}
169 deletions={seed.deletions}
170 className="font-mono text-xs"
171 />
172 ) : (
173 <GhostBar className="w-20" />
174 )}
95175 </div>
96176 </div>
97177 </div>
......@@ -102,7 +182,19 @@ export function PullRequestDetailGhost() {
102182 <GhostBar className="h-6 w-16 rounded-md" />
103183 <GhostBar className="h-6 w-12 rounded-md" />
104184 </div>
105 <GhostBar className="w-20" />
185 {checksPresentation ? (
186 <span
187 className={cn(
188 "inline-flex items-center gap-1.5 text-xs",
189 checksPresentation.toneClassName,
190 )}
191 >
192 <checksPresentation.Icon aria-hidden className="size-3.5" />
193 {checksPresentation.label}
194 </span>
195 ) : (
196 <GhostBar className="w-20" />
197 )}
106198 </div>
107199 </div>
108200
......@@ -125,8 +217,29 @@ export function PullRequestDetailGhost() {
125217 <GhostBar className="w-10" />
126218 </div>
127219 <div className="flex items-center gap-1">
128 <GhostBar className="h-5 w-24 rounded-full" />
129 <GhostBar className="h-5 w-20 rounded-full" />
220 {seed ? (
221 seed.labels.slice(0, 3).map((label) => {
222 const color = pullRequestLabelColor(label.color);
223 return (
224 <span
225 key={label.name}
226 className="inline-flex h-5 max-w-32 items-center gap-1 truncate rounded-full border border-border/70 bg-muted/40 px-2 text-[10px] text-muted-foreground"
227 >
228 <span
229 aria-hidden
230 className="size-2 shrink-0 rounded-full bg-muted-foreground"
231 {...(color ? { style: { backgroundColor: color } } : {})}
232 />
233 <span className="truncate">{label.name}</span>
234 </span>
235 );
236 })
237 ) : (
238 <>
239 <GhostBar className="h-5 w-24 rounded-full" />
240 <GhostBar className="h-5 w-20 rounded-full" />
241 </>
242 )}
130243 </div>
131244 </div>
132245 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
apps/web/src/routes/_chat.pull-requests.tsx+17
......@@ -220,6 +220,9 @@ const EMPTY_TERMINAL_LABELS = new Map<string, string>();
220220const EMPTY_PENDING_SURFACES = new Set<string>();
221221const MAX_SEARCH_LABEL_CANDIDATES = 100;
222222
223const pullRequestListEntryId = (target: Parameters<typeof pullRequestSurfaceId>[0]) =>
224 pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() });
225
223226function pullRequestSearchLabels(raw: unknown): Partial<Pick<PullRequestsSearch, "labels">> {
224227 const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice(
225228 0,
......@@ -1436,6 +1439,15 @@ function PullRequestsRouteView() {
14361439 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
14371440 );
14381441 }, [groups, sort, statsByRow, typedParsed.text]);
1442 const listedPullRequestsBySurface = useMemo(
1443 () =>
1444 new Map(
1445 displayGroups.flatMap((group) =>
1446 group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const),
1447 ),
1448 ),
1449 [displayGroups],
1450 );
14391451
14401452 const linkedSelection = useMemo(
14411453 () =>
......@@ -1939,6 +1951,11 @@ function PullRequestsRouteView() {
19391951 repository: renderedPullRequestSurface.repository,
19401952 number: renderedPullRequestSurface.number,
19411953 }}
1954 listEntry={
1955 listedPullRequestsBySurface.get(
1956 pullRequestListEntryId(renderedPullRequestSurface),
1957 ) ?? null
1958 }
19421959 refreshToken={detailRefreshToken}
19431960 // Merging, closing or reopening changes the row this panel was opened from, so
19441961 // the list behind it is out of date the moment the host takes the action.