authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-20 13:25:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 13:25:46-07:00
log6a699f0f2fbd8847d7ec2df8d9245ce8c2eb8707
tree25c91eccd8e82b167a684b3f9e96df0d59a16a92
parent9f0c9f725d4553d34e3c91c13d67ebcc58be5b0f
signature Signed by PGP key B5690EEEBB952194

fix(web): stabilize pull request loading layout (#12721)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

6 files changed, 419 insertions(+), 214 deletions(-)

apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx created+66
......@@ -0,0 +1,66 @@
1import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2import { cn } from "~/lib/utils";
3
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5
6export function PullRequestCopyableCode({
7 value,
8 target,
9 copyLabel,
10 copiedLabel,
11 className,
12 tooltipSide = "top",
13 onError,
14}: {
15 readonly value: string;
16 readonly target: string;
17 readonly copyLabel: string;
18 readonly copiedLabel: string;
19 readonly className?: string;
20 readonly tooltipSide?: "top" | "bottom";
21 readonly onError?: (error: Error) => void;
22}) {
23 const { copyToClipboard, isCopied } = useCopyToClipboard({
24 target,
25 timeout: 1600,
26 ...(onError ? { onError } : {}),
27 });
28 return (
29 <Tooltip>
30 <TooltipTrigger
31 render={
32 <button
33 type="button"
34 className={cn(
35 "relative grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
36 className,
37 )}
38 aria-label={isCopied ? copiedLabel : copyLabel}
39 onClick={() => copyToClipboard(value)}
40 />
41 }
42 >
43 <code
44 className={cn(
45 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
46 isCopied ? "opacity-0" : "opacity-100",
47 )}
48 >
49 {value}
50 </code>
51 <span
52 aria-hidden="true"
53 className={cn(
54 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
55 isCopied ? "opacity-100" : "opacity-0",
56 )}
57 >
58 Copied
59 </span>
60 </TooltipTrigger>
61 <TooltipPopup className="max-w-96 wrap-anywhere font-mono" side={tooltipSide}>
62 {`${isCopied ? "Copied" : copyLabel}: ${value}`}
63 </TooltipPopup>
64 </Tooltip>
65 );
66}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+31-82
......@@ -121,6 +121,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
121121import { toastManager } from "../ui/toast";
122122import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
123123import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
124import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
124125import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
125126import { DiffPanelLoadingState } from "../DiffPanelShell";
126127import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
......@@ -140,6 +141,7 @@ import {
140141 handoffPrompt,
141142 handoffReviewComments,
142143 latestPullRequestReviewOutcomes,
144 loadingPullRequestCheckoutCommand,
143145 isStackedPullRequestBase,
144146 pullRequestActionMenuHasGroup,
145147 pullRequestActionNeedsHostRefresh,
......@@ -326,68 +328,6 @@ const openNumberContextMenu = (
326328 });
327329};
328330
329function PullRequestCopyableCode({
330 value,
331 target,
332 copyLabel,
333 copiedLabel,
334 className,
335 tooltipSide = "top",
336 onError,
337}: {
338 readonly value: string;
339 readonly target: string;
340 readonly copyLabel: string;
341 readonly copiedLabel: string;
342 readonly className?: string;
343 readonly tooltipSide?: "top" | "bottom";
344 readonly onError?: (error: Error) => void;
345}) {
346 const { copyToClipboard, isCopied } = useCopyToClipboard({
347 target,
348 timeout: 1600,
349 ...(onError ? { onError } : {}),
350 });
351 return (
352 <Tooltip>
353 <TooltipTrigger
354 render={
355 <button
356 type="button"
357 className={cn(
358 "relative grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
359 className,
360 )}
361 aria-label={isCopied ? copiedLabel : copyLabel}
362 onClick={() => copyToClipboard(value)}
363 />
364 }
365 >
366 <code
367 className={cn(
368 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
369 isCopied ? "opacity-0" : "opacity-100",
370 )}
371 >
372 {value}
373 </code>
374 <span
375 aria-hidden="true"
376 className={cn(
377 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
378 isCopied ? "opacity-100" : "opacity-0",
379 )}
380 >
381 Copied
382 </span>
383 </TooltipTrigger>
384 <TooltipPopup className="max-w-96 wrap-anywhere font-mono" side={tooltipSide}>
385 {`${isCopied ? "Copied" : copyLabel}: ${value}`}
386 </TooltipPopup>
387 </Tooltip>
388 );
389}
390
391331/**
392332 * The stale-branch warning, said beside the branch it is about rather than as a bar of its own.
393333 * The banner this replaces held a row of chrome open across the top of every pull request that
......@@ -804,15 +744,22 @@ export function PullRequestDetailPanel({
804744 repositoryUrl !== null
805745 ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString()
806746 : null;
807 const checkoutCommand = detail
747 const checkoutCommand = handoffSummary
808748 ? pullRequestCheckoutCommand(
809 detail.provider,
810 detail.number,
811 detail.headBranch,
812 detail.headRepositoryNameWithOwner,
813 repositoryUrl,
749 handoffSummary.provider,
750 handoffSummary.number,
751 handoffSummary.headBranch,
752 detail?.headRepositoryNameWithOwner,
753 changeRequestRepositoryUrl(handoffSummary.url),
814754 )
815 : null;
755 : loadingPullRequestCheckoutCommand(reference, repositoryIdentity);
756 const onCheckoutCommandError = useCallback((error: Error) => {
757 toastManager.add({
758 type: "error",
759 title: "Could not copy checkout command",
760 description: error.message,
761 });
762 }, []);
816763 const branchRefsQuery = useEnvironmentQuery(
817764 detail === null
818765 ? null
......@@ -1475,8 +1422,9 @@ export function PullRequestDetailPanel({
14751422 // Out of date with the base, and still cleanly mergeable — the one pairing an update button
14761423 // exists for. Null everywhere else, including hosts that cannot compare at all.
14771424 const freshness = detail === null ? null : resolveBaseFreshness(detail);
1478 // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden
1479 // until the detail arrives, so the loading ghost is the panel's only unfinished UI.
1425 // A host that cannot produce a patch has no Code tab to open. While detail is loading the ghost
1426 // uses this optimistic tab set to reserve the same chrome; a host without a patch removes Code
1427 // when its capabilities arrive.
14801428 const visibleTabs = TABS.filter(
14811429 (item) => item.value !== "code" || detail === null || detail.capabilities.diff,
14821430 );
......@@ -1664,6 +1612,13 @@ export function PullRequestDetailPanel({
16641612 <PullRequestDetailGhost
16651613 seed={matchingListEntry}
16661614 summary={sharedSummary}
1615 checkoutCommand={checkoutCommand}
1616 onCheckoutError={onCheckoutCommandError}
1617 number={reference.number}
1618 tabs={visibleTabs}
1619 activeTab={tab}
1620 {...(onBack ? { onBack } : {})}
1621 {...(onClose ? { onClose } : {})}
16671622 actions={
16681623 handoffSummary ? (
16691624 <TooltipProvider delay={150} closeDelay={150} timeout={400}>
......@@ -2371,7 +2326,7 @@ export function PullRequestDetailPanel({
23712326 {detail ? (
23722327 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
23732328 {titleDraft === null ? (
2374 <div className="group flex min-w-0 items-center gap-1">
2329 <div className="group flex min-h-7 min-w-0 items-center gap-1 sm:min-h-6">
23752330 <Tooltip>
23762331 <TooltipTrigger
23772332 render={
......@@ -2432,7 +2387,7 @@ export function PullRequestDetailPanel({
24322387 </div>
24332388 </div>
24342389 )}
2435 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
2390 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
24362391 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
24372392 <PullRequestActorLabel
24382393 actor={detail.author}
......@@ -2450,18 +2405,12 @@ export function PullRequestDetailPanel({
24502405 copiedLabel="Checkout command copied"
24512406 className="ml-auto font-mono"
24522407 tooltipSide="bottom"
2453 onError={(error) =>
2454 toastManager.add({
2455 type: "error",
2456 title: "Could not copy checkout command",
2457 description: error.message,
2458 })
2459 }
2408 onError={onCheckoutCommandError}
24602409 />
24612410 ) : null}
24622411 </div>
24632412
2464 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
2413 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
24652414 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
24662415 <Tooltip>
24672416 <TooltipTrigger
......@@ -2504,7 +2453,7 @@ export function PullRequestDetailPanel({
25042453 />
25052454 </span>
25062455 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
2507 <span className="inline-flex items-center gap-1.5 tabular-nums">
2456 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">
25082457 <FileDiffIcon className="size-3.5" />
25092458 {detail.changedFiles.toLocaleString()}{" "}
25102459 {detail.changedFiles === 1 ? "file" : "files"}
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+259-131
......@@ -8,16 +8,31 @@
88 * number of bars costs one opacity animation.
99 */
1010import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts";
11import { ArrowLeftIcon } from "lucide-react";
11import {
12 ArrowLeftIcon,
13 ChevronRightIcon,
14 EllipsisIcon,
15 ExternalLinkIcon,
16 FileDiffIcon,
17 PanelRightIcon,
18 TagIcon,
19 UserPlusIcon,
20 UsersIcon,
21} from "lucide-react";
1222import type { ReactNode } from "react";
1323
24import { readLocalApi } from "~/localApi";
1425import { cn } from "~/lib/utils";
1526import { formatRelativeTimeLabel } from "~/timestampFormat";
1627
28import { Button, InlineButton } from "../ui/button";
29import { Toggle, ToggleGroup } from "../ui/toggle-group";
30import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
1731import { pullRequestLabelColor } from "./pullRequestList.logic";
1832import {
1933 PullRequestActorLabel,
2034 PullRequestDiffStat,
35 PullRequestMetaLine,
2136 pullRequestChecksStatePresentation,
2237 resolvePullRequestState,
2338} from "./pullRequestPresentation";
......@@ -29,6 +44,11 @@ function GhostBar({ className }: { className?: string | undefined }) {
2944/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */
3045const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"];
3146const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"];
47const DEFAULT_DETAIL_TABS = [
48 { value: "summary", label: "Summary" },
49 { value: "timeline", label: "Timeline" },
50 { value: "code", label: "Code" },
51] as const;
3252
3353/** Rows in the list's own grid — glyph, title over meta, time over diffstat. */
3454export function PullRequestListGhost({
......@@ -77,10 +97,25 @@ export function PullRequestDetailGhost({
7797 seed: entry,
7898 summary,
7999 actions,
100 checkoutCommand,
101 tabs = DEFAULT_DETAIL_TABS,
102 activeTab,
103 number,
104 onBack,
105 onClose,
106 onCheckoutError,
80107}: {
81108 seed?: PullRequestListEntry | null;
82109 summary?: PullRequestSummary | null;
83110 actions?: ReactNode;
111 checkoutCommand?: string | null;
112 tabs?: ReadonlyArray<{ value: string; label: string }>;
113 /** The panel's current tab, so the highlight does not jump when the detail arrives. */
114 activeTab?: string;
115 number?: number;
116 onBack?: (() => void) | undefined;
117 onClose?: (() => void) | undefined;
118 onCheckoutError?: ((error: Error) => void) | undefined;
84119}) {
85120 const seed = summary
86121 ? {
......@@ -100,6 +135,10 @@ export function PullRequestDetailGhost({
100135 seed?.checksState === "failing" || seed?.checksState === "pending"
101136 ? pullRequestChecksStatePresentation(seed.checksState)
102137 : null;
138 const checkout = checkoutCommand ?? null;
139 const changedFiles = summary?.changedFiles ?? null;
140 const selectedTab =
141 tabs.find((item) => item.value === activeTab)?.value ?? tabs[0]?.value ?? "summary";
103142
104143 return (
105144 <div
......@@ -110,100 +149,178 @@ export function PullRequestDetailGhost({
110149 !seed && "motion-safe:animate-skeleton",
111150 )}
112151 >
113 <div className="@container/pr-header shrink-0 border-b border-border/60">
114 <div className="flex h-7 items-center justify-between gap-3 px-4">
115 <div className="flex min-w-0 flex-1 items-center gap-1.5">
116 {seed && statePresentation ? (
152 <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
153 <div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden">
154 <div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground sm:text-xs">
155 {onBack ? (
156 <Button
157 size="icon-micro"
158 variant="ghost-muted"
159 className="-ml-1.5"
160 onClick={onBack}
161 aria-label="Back to this thread's pull requests"
162 >
163 <ArrowLeftIcon aria-hidden className="size-3.5" />
164 </Button>
165 ) : null}
166 {seed ? (
117167 <>
118 <span className="min-w-0 truncate text-xs font-medium text-muted-foreground">
119 {seed.repository}
120 </span>
121 <span
122 className={cn("shrink-0 text-xs font-medium", statePresentation.toneClassName)}
168 <span className="min-w-0 truncate font-medium">{seed.repository}</span>
169 <InlineButton
170 onClick={() => void readLocalApi()?.shell.openExternal(seed.url)}
171 className={cn(
172 "font-medium underline-offset-2 hover:underline",
173 statePresentation?.toneClassName,
174 )}
175 aria-label={`Open pull request #${seed.number} on host`}
123176 >
124177 #{seed.number}
125 </span>
178 <ExternalLinkIcon aria-hidden className="size-2.5" />
179 </InlineButton>
126180 </>
127181 ) : (
128182 <>
129183 <GhostBar className="w-24" />
130 <GhostBar className="w-9" />
184 <span className="shrink-0 text-xs text-muted-foreground">#{number ?? "…"}</span>
131185 </>
132186 )}
133187 </div>
134 <div className="flex shrink-0 items-center gap-1">
135 {actions ?? <GhostBar className="h-5 w-16 rounded-md" />}
136 <GhostBar className="size-6 rounded-md" />
137 </div>
188 </div>
189 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
190 {actions ?? <GhostBar className="h-6 w-16 rounded-md" />}
191 <Button size="icon-xs" variant="ghost" disabled aria-label="Pull request actions loading">
192 <EllipsisIcon aria-hidden className="size-4" />
193 </Button>
194 {onClose ? (
195 <Button
196 size="icon-xs"
197 variant="ghost"
198 aria-label="Collapse pull request panel"
199 onClick={onClose}
200 >
201 <PanelRightIcon aria-hidden className="size-3.5" />
202 </Button>
203 ) : null}
138204 </div>
139205
140 <div className="px-4 pb-4 pt-1">
141 {seed ? (
142 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>
143 ) : (
144 <GhostBar className="h-5 w-4/5 max-w-md" />
145 )}
146 <div className="mt-2 flex items-center gap-1.5">
147 {seed ? (
148 <>
149 <PullRequestActorLabel
150 actor={seed.author ?? null}
151 className="font-medium"
152 tooltip={false}
153 />
154 <span className="text-xs text-muted-foreground">
155 updated {formatRelativeTimeLabel(seed.updatedAt)}
156 </span>
157 </>
158 ) : (
159 <>
160 <GhostBar className="size-4 rounded-full" />
161 <GhostBar className="w-24" />
162 </>
163 )}
164 </div>
165 <div className="mt-4 flex min-w-0 items-center gap-2">
166 {seed ? (
167 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
168 <code className="min-w-0 max-w-[40%] shrink-0 truncate">{seed.baseBranch}</code>
169 <ArrowLeftIcon
170 aria-label="receives changes from"
171 className="size-3.5 shrink-0 opacity-60"
172 />
173 <code className="min-w-0 flex-1 truncate">{seed.headBranch}</code>
174 </span>
175 ) : (
176 <>
177 <GhostBar className="h-6 w-24 rounded-md" />
178 <GhostBar className="size-3 rounded-full" />
179 <GhostBar className="h-6 w-32 rounded-md" />
180 </>
181 )}
182 <div className="ml-auto flex shrink-0 items-center gap-2">
183 <GhostBar className="w-10" />
206 <div className="col-span-2 grid grid-rows-[1fr]">
207 <div className="min-h-0 overflow-hidden">
208 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
184209 {seed ? (
185 <PullRequestDiffStat
186 additions={seed.additions ?? 0}
187 deletions={seed.deletions ?? 0}
188 className="font-mono text-xs"
189 />
210 <div className="flex min-h-7 min-w-0 items-center sm:min-h-6">
211 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>
212 </div>
190213 ) : (
191 <GhostBar className="w-20" />
214 <div className="flex min-h-7 min-w-0 items-center sm:min-h-6">
215 <GhostBar className="h-5 w-4/5 max-w-md" />
216 </div>
192217 )}
218 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
219 {seed ? (
220 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
221 <PullRequestActorLabel
222 actor={seed.author ?? null}
223 className="font-medium"
224 tooltip={false}
225 />
226 <span>updated {formatRelativeTimeLabel(seed.updatedAt)}</span>
227 </PullRequestMetaLine>
228 ) : (
229 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
230 <span className="flex items-center gap-1.5">
231 <GhostBar className="size-4 rounded-full" />
232 <GhostBar className="h-3 w-14" />
233 </span>
234 <GhostBar className="h-3 w-16" />
235 </PullRequestMetaLine>
236 )}
237 {checkout ? (
238 <PullRequestCopyableCode
239 key={checkout}
240 value={checkout}
241 target="pull request checkout command"
242 copyLabel="Copy checkout command"
243 copiedLabel="Checkout command copied"
244 className="ml-auto font-mono"
245 tooltipSide="bottom"
246 {...(onCheckoutError ? { onError: onCheckoutError } : {})}
247 />
248 ) : null}
249 </div>
250
251 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
252 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
253 {seed ? (
254 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
255 <code className="min-w-0 truncate">{seed.baseBranch}</code>
256 </span>
257 ) : (
258 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
259 <GhostBar className="h-3 w-12" />
260 </span>
261 )}
262 <ArrowLeftIcon
263 aria-label="receives changes from"
264 className="size-3.5 shrink-0 opacity-60"
265 />
266 {seed ? (
267 <PullRequestCopyableCode
268 key={seed.headBranch}
269 value={seed.headBranch}
270 target="branch name"
271 copyLabel="Copy pull request branch"
272 copiedLabel="Branch name copied"
273 className="min-w-0 font-mono"
274 />
275 ) : (
276 <GhostBar className="h-3 w-32 flex-1" />
277 )}
278 </span>
279 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
280 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">
281 <FileDiffIcon aria-hidden className="size-3.5" />
282 {changedFiles === null ? (
283 <GhostBar className="h-3 w-10" />
284 ) : (
285 `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}`
286 )}
287 </span>
288 {seed ? (
289 <PullRequestDiffStat
290 additions={seed.additions ?? 0}
291 deletions={seed.deletions ?? 0}
292 className="shrink-0 font-mono text-xs"
293 />
294 ) : (
295 <GhostBar className="h-3 w-20" />
296 )}
297 </span>
298 </div>
193299 </div>
194300 </div>
195301 </div>
196302
197 <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2">
198 <div className="flex items-center gap-1 p-0.5">
199 <GhostBar className="h-6 w-16 rounded-md" />
200 <GhostBar className="h-6 w-16 rounded-md" />
201 <GhostBar className="h-6 w-12 rounded-md" />
202 </div>
303 <nav
304 className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2"
305 aria-label="Pull request tabs"
306 inert
307 >
308 <ToggleGroup
309 className="shrink-0"
310 size="segmented"
311 variant="segmented"
312 value={[selectedTab]}
313 >
314 {tabs.map((tab) => (
315 <Toggle key={tab.value} value={tab.value} tabIndex={-1}>
316 {tab.label}
317 </Toggle>
318 ))}
319 </ToggleGroup>
203320 {checksPresentation ? (
204321 <span
205322 className={cn(
206 "inline-flex items-center gap-1.5 text-xs",
323 "ml-auto inline-flex items-center gap-1.5 text-xs",
207324 checksPresentation.toneClassName,
208325 )}
209326 >
......@@ -211,74 +328,85 @@ export function PullRequestDetailGhost({
211328 {checksPresentation.label}
212329 </span>
213330 ) : (
214 <GhostBar className="w-20" />
331 <GhostBar className="ml-auto h-3 w-32" />
215332 )}
216 </div>
333 </nav>
217334 </div>
218335
219336 <div className="min-h-0 flex-1 overflow-hidden">
220 <section className="px-4 py-3">
221 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
222 <div className="flex items-center gap-1.5">
223 <GhostBar className="size-3.5 rounded-full" />
224 <GhostBar className="w-14" />
225 </div>
226 <div className="flex items-center gap-1">
227 <GhostBar className="size-4 rounded-full" />
228 <GhostBar className="size-4 rounded-full" />
229 <GhostBar className="ml-1 size-5 rounded-md" />
230 </div>
231 </div>
232 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
233 <div className="flex items-center gap-1.5">
234 <GhostBar className="size-3.5 rounded-full" />
235 <GhostBar className="w-10" />
236 </div>
237 <div className="flex items-center gap-1">
238 {seed?.labels ? (
239 seed.labels.slice(0, 3).map((label) => {
240 const color = pullRequestLabelColor(label.color);
241 return (
242 <span
243 key={label.name}
244 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"
245 >
246 <span
247 aria-hidden
248 className="size-2 shrink-0 rounded-full bg-muted-foreground"
249 {...(color ? { style: { backgroundColor: color } } : {})}
250 />
251 <span className="truncate">{label.name}</span>
252 </span>
253 );
254 })
255 ) : (
256 <>
257 <GhostBar className="h-5 w-24 rounded-full" />
258 <GhostBar className="h-5 w-20 rounded-full" />
259 </>
260 )}
337 <section className="px-4 pt-2.5 pb-1">
338 <div className="space-y-2">
339 <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6">
340 <span className="flex items-center gap-1.5 text-muted-foreground">
341 <UsersIcon aria-hidden className="size-3.5" />
342 Reviewers
343 </span>
344 <span className="flex min-w-0 items-center gap-1.5 text-foreground">
345 <GhostBar className="h-3 w-10" />
346 <Button size="icon-xs" variant="ghost" disabled aria-label="Reviewers loading">
347 <UserPlusIcon aria-hidden className="size-3.5" />
348 </Button>
349 </span>
261350 </div>
262 </div>
263 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
264 <div className="flex items-center gap-1.5">
265 <GhostBar className="size-3.5 rounded-full" />
266 <GhostBar className="w-14" />
351 <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6">
352 <span className="flex items-center gap-1.5 text-muted-foreground">
353 <TagIcon aria-hidden className="size-3.5" />
354 Labels
355 </span>
356 <span className="flex min-w-0 flex-wrap items-center gap-1 text-foreground">
357 {entry?.labels ? (
358 entry.labels.length > 0 ? (
359 entry.labels.map((label) => {
360 const color = pullRequestLabelColor(label.color);
361 return (
362 <span
363 key={label.name}
364 className="inline-flex max-w-48 items-center gap-1.5 truncate rounded-full bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs"
365 >
366 <span
367 aria-hidden
368 className="size-2 shrink-0 rounded-full bg-muted-foreground"
369 {...(color ? { style: { backgroundColor: color } } : {})}
370 />
371 <span className="truncate">{label.name}</span>
372 </span>
373 );
374 })
375 ) : (
376 <span className="text-muted-foreground">None</span>
377 )
378 ) : (
379 <>
380 <GhostBar className="h-4 w-24 rounded-full" />
381 <GhostBar className="h-4 w-20 rounded-full" />
382 </>
383 )}
384 <Button size="icon-xs" variant="ghost" disabled aria-label="Labels loading">
385 <TagIcon aria-hidden className="size-3.5" />
386 </Button>
387 </span>
267388 </div>
268 <GhostBar className="w-20" />
269389 </div>
270390 </section>
271391
272 <section className="border-t border-border/60">
273 <div className="flex min-h-11 items-center gap-1.5 px-4 py-3">
274 <GhostBar className="h-4 w-24" />
275 <GhostBar className="size-3.5 rounded-full" />
392 <section>
393 <div className="sticky top-0 z-10 flex w-full items-center bg-background pr-4">
394 <div className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-xs font-medium text-muted-foreground">
395 <span>Description</span>
396 <ChevronRightIcon
397 aria-hidden
398 className="size-3.5 rotate-90 text-muted-foreground/60"
399 />
400 </div>
276401 </div>
277 <div className="space-y-2 px-4 pb-4">
278 <GhostBar className="w-full" />
279 <GhostBar className="w-11/12" />
280 <GhostBar className="w-4/5" />
281 <GhostBar className="w-2/3" />
402 <div className="space-y-3 px-4 pb-4">
403 <GhostBar className="h-5 w-40" />
404 <div className="space-y-2">
405 <GhostBar className="h-4 w-full" />
406 <GhostBar className="h-4 w-11/12" />
407 <GhostBar className="h-4 w-4/5" />
408 <GhostBar className="h-4 w-2/3" />
409 </div>
282410 </div>
283411 </section>
284412 </div>
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+1-1
......@@ -275,7 +275,7 @@ function MetaRow({
275275 children: ReactNode;
276276}) {
277277 return (
278 <div className="grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs">
278 <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6">
279279 <span className="flex items-center gap-1.5 text-muted-foreground">
280280 {icon}
281281 {label}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+46
......@@ -7,7 +7,9 @@ import {
77 type PullRequestComment,
88 type PullRequestDetail,
99 type PullRequestDetailView,
10 type PullRequestRef,
1011 type PullRequestReviewThread,
12 type RepositoryIdentity,
1113 type ThreadPullRequestLink,
1214} from "@t3tools/contracts";
1315import { describe, expect, it } from "vite-plus/test";
......@@ -27,6 +29,7 @@ import {
2729 stripPullRequestHandoffReferences,
2830 isPullRequestVerdictStale,
2931 isStackedPullRequestBase,
32 loadingPullRequestCheckoutCommand,
3033 pullRequestPanelContext,
3134 latestPullRequestReviewOutcomes,
3235 newestPullRequestCommitAt,
......@@ -94,6 +97,49 @@ describe("pull request checkout commands", () => {
9497 "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
9598 );
9699 });
100
101 const reference = (host?: string): PullRequestRef => ({
102 projectId: ProjectId.make("project-1"),
103 ...(host === undefined ? {} : { host }),
104 repository: "acme/web",
105 number: 42,
106 });
107 const identity = (provider: string, canonicalKey: string): RepositoryIdentity => ({
108 canonicalKey,
109 locator: {
110 source: "git-remote",
111 remoteName: "origin",
112 remoteUrl: "git@github.com:acme/web.git",
113 },
114 provider,
115 });
116
117 it("uses a public host when no repository identity is available", () => {
118 expect(loadingPullRequestCheckoutCommand(reference("github.com"), undefined)).toBe(
119 "gh pr checkout 42",
120 );
121 expect(loadingPullRequestCheckoutCommand(reference("gitlab.com"), null)).toBe(
122 "glab mr checkout 42",
123 );
124 });
125
126 it("uses a matching enterprise identity and rejects an explicit host mismatch", () => {
127 const enterprise = identity("github", "github.example.test/acme/web");
128 expect(loadingPullRequestCheckoutCommand(reference("github.example.test"), enterprise)).toBe(
129 "gh pr checkout 42",
130 );
131 expect(loadingPullRequestCheckoutCommand(reference("github.com"), enterprise)).toBeNull();
132 });
133
134 it("does not infer a number-only command without a trusted provider", () => {
135 expect(loadingPullRequestCheckoutCommand(reference(), undefined)).toBeNull();
136 expect(
137 loadingPullRequestCheckoutCommand(
138 reference("github.com"),
139 identity("gitlab", "gitlab.com/acme/web"),
140 ),
141 ).toBeNull();
142 });
97143});
98144
99145const TIMELINE_SOURCE: Pick<
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+16
......@@ -140,6 +140,22 @@ export function pullRequestCheckoutCommand(
140140 }
141141}
142142
143/** Build a checkout command from identity metadata while the detail request is still pending. */
144export function loadingPullRequestCheckoutCommand(
145 reference: PullRequestRef,
146 identity: RepositoryIdentity | null | undefined,
147): string | null {
148 const host = reference.host?.trim().toLowerCase();
149 const provider =
150 identity?.provider ??
151 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);
152 if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null;
153 if (identity?.provider !== undefined && host && pullRequestHostOf(identity, provider) !== host) {
154 return null;
155 }
156 return pullRequestCheckoutCommand(provider, reference.number, "");
157}
158
143159/** Activity changes only when the same host resource reports a newer revision. */
144160export function shouldRefreshPullRequestActivity(
145161 previous: { readonly key: string; readonly updatedAt: string } | null,