| ... | @@ -15,7 +15,7 @@ import { | ... | @@ -15,7 +15,7 @@ import { |
| 15 | TagIcon, | 15 | TagIcon, |
| 16 | UsersIcon, | 16 | UsersIcon, |
| 17 | } from "lucide-react"; | 17 | } from "lucide-react"; |
| 18 | import { useId, useRef, useState, type ReactNode } from "react"; | 18 | import { useRef, useState, type ReactNode } from "react"; |
| 19 | | 19 | |
| 20 | import { useAtomCommand } from "~/state/use-atom-command"; | 20 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 21 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 21 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| ... | @@ -237,11 +237,13 @@ function MetaRow({ | ... | @@ -237,11 +237,13 @@ function MetaRow({ |
| 237 | function Section({ | 237 | function Section({ |
| 238 | title, | 238 | title, |
| 239 | defaultOpen = true, | 239 | defaultOpen = true, |
| | 240 | keepMounted = false, |
| 240 | actions, | 241 | actions, |
| 241 | children, | 242 | children, |
| 242 | }: { | 243 | }: { |
| 243 | title: string; | 244 | title: string; |
| 244 | defaultOpen?: boolean; | 245 | defaultOpen?: boolean; |
| | 246 | keepMounted?: boolean; |
| 245 | /** Heading controls stay separate from the collapse trigger so they remain independently usable. */ | 247 | /** Heading controls stay separate from the collapse trigger so they remain independently usable. */ |
| 246 | actions?: ReactNode; | 248 | actions?: ReactNode; |
| 247 | children: ReactNode; | 249 | children: ReactNode; |
| ... | @@ -271,6 +273,7 @@ function Section({ | ... | @@ -271,6 +273,7 @@ function Section({ |
| 271 | <Collapsible | 273 | <Collapsible |
| 272 | open={open} | 274 | open={open} |
| 273 | onOpenChange={setOpenWithScrollAnchor} | 275 | onOpenChange={setOpenWithScrollAnchor} |
| | 276 | render={<section aria-label={title} />} |
| 274 | data-pull-request-summary-section | 277 | data-pull-request-summary-section |
| 275 | > | 278 | > |
| 276 | {/* The heading rides the top of the scroll box the way a diff's file header does, so a | 279 | {/* The heading rides the top of the scroll box the way a diff's file header does, so a |
| ... | @@ -292,7 +295,7 @@ function Section({ | ... | @@ -292,7 +295,7 @@ function Section({ |
| 292 | </CollapsibleTrigger> | 295 | </CollapsibleTrigger> |
| 293 | {actions} | 296 | {actions} |
| 294 | </div> | 297 | </div> |
| 295 | <CollapsiblePanel> | 298 | <CollapsiblePanel keepMounted={keepMounted}> |
| 296 | <div className="px-4 pb-4">{children}</div> | 299 | <div className="px-4 pb-4">{children}</div> |
| 297 | </CollapsiblePanel> | 300 | </CollapsiblePanel> |
| 298 | </Collapsible> | 301 | </Collapsible> |
| ... | @@ -334,9 +337,6 @@ export function PullRequestSummaryTab({ | ... | @@ -334,9 +337,6 @@ export function PullRequestSummaryTab({ |
| 334 | // Keyed by the pull request, so opening another one starts at the end of its conversation | 337 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 335 | // rather than wherever the last one had been read back to. | 338 | // rather than wherever the last one had been read back to. |
| 336 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); | 339 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| 337 | const checksId = useId(); | | |
| 338 | const [expandedChecksUrl, setExpandedChecksUrl] = useState<string | null>(null); | | |
| 339 | const showChecks = expandedChecksUrl === detail.url; | | |
| 340 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; | 340 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| 341 | // Windowed by recency regardless of display order: expanding always reaches further back in | 341 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 342 | // time, whether the newest comment currently reads first or last. | 342 | // time, whether the newest comment currently reads first or last. |
| ... | @@ -472,7 +472,7 @@ export function PullRequestSummaryTab({ | ... | @@ -472,7 +472,7 @@ export function PullRequestSummaryTab({ |
| 472 | | 472 | |
| 473 | return ( | 473 | return ( |
| 474 | <div className="h-full overflow-y-auto" data-pull-request-summary-scroll> | 474 | <div className="h-full overflow-y-auto" data-pull-request-summary-scroll> |
| 475 | <section className="px-4 py-2.5"> | 475 | <section className="px-4 pt-2.5 pb-1"> |
| 476 | <div className="space-y-2"> | 476 | <div className="space-y-2"> |
| 477 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> | 477 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> |
| 478 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> | 478 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| ... | @@ -601,7 +601,7 @@ export function PullRequestSummaryTab({ | ... | @@ -601,7 +601,7 @@ export function PullRequestSummaryTab({ |
| 601 | </div> | 601 | </div> |
| 602 | </section> | 602 | </section> |
| 603 | | 603 | |
| 604 | <section aria-label="Description" className="px-4 pt-2 pb-1"> | 604 | <Section key={`description:${detail.url}`} title="Description" keepMounted> |
| 605 | <div className="group"> | 605 | <div className="group"> |
| 606 | {bodyScope === detail.url ? ( | 606 | {bodyScope === detail.url ? ( |
| 607 | <PullRequestMarkdownEditor | 607 | <PullRequestMarkdownEditor |
| ... | @@ -640,78 +640,58 @@ export function PullRequestSummaryTab({ | ... | @@ -640,78 +640,58 @@ export function PullRequestSummaryTab({ |
| 640 | </div> | 640 | </div> |
| 641 | )} | 641 | )} |
| 642 | </div> | 642 | </div> |
| 643 | </section> | 643 | </Section> |
| 644 | | 644 | |
| 645 | <section aria-label="Checks" className="px-4 py-3"> | 645 | <Section key={`checks:${detail.url}`} title="Checks" defaultOpen={false}> |
| 646 | {detail.checks.length === 0 ? ( | 646 | {detail.checks.length === 0 ? ( |
| 647 | <p className="text-xs text-muted-foreground">No checks reported.</p> | 647 | <p className="text-xs text-muted-foreground">No checks reported.</p> |
| 648 | ) : ( | 648 | ) : ( |
| 649 | <div> | 649 | detail.checks.map((check, index) => { |
| 650 | <div className="flex items-center gap-1 text-xs"> | 650 | const finding = { kind: "check", check } as const; |
| 651 | <span className="font-medium text-muted-foreground">Checks</span> | 651 | const failing = check.status === "failure" || check.status === "cancelled"; |
| 652 | <Button | 652 | return ( |
| 653 | size="icon-xs" | 653 | <div |
| 654 | variant="ghost-muted" | 654 | // Position too: the host decides how many runs share a name, and a repeated |
| 655 | aria-label={showChecks ? "Hide checks" : "Show checks"} | 655 | // key would be a rendering fault on top of whatever the list already says. |
| 656 | aria-expanded={showChecks} | 656 | key={`${index}:${check.name}:${check.url ?? ""}`} |
| 657 | aria-controls={checksId} | 657 | className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60" |
| 658 | onClick={() => setExpandedChecksUrl(showChecks ? null : detail.url)} | | |
| 659 | > | 658 | > |
| 660 | <ChevronRightIcon | 659 | <button |
| 661 | aria-hidden | 660 | type="button" |
| 662 | className={cn("size-3.5 text-muted-foreground/60", showChecks && "rotate-90")} | 661 | disabled={!check.url} |
| 663 | /> | 662 | onClick={() => check.url && openCheck(check.url)} |
| 664 | </Button> | 663 | className={cn( |
| 665 | </div> | 664 | "flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left text-xs leading-5 [&>svg]:mt-0.5", |
| 666 | <div id={checksId} className={showChecks ? "mt-2" : "hidden"}> | 665 | check.url ? "cursor-pointer" : "cursor-default", |
| 667 | {(showChecks ? detail.checks : []).map((check, index) => { | 666 | )} |
| 668 | const finding = { kind: "check", check } as const; | 667 | > |
| 669 | const failing = check.status === "failure" || check.status === "cancelled"; | 668 | <PullRequestCheckStatusIcon status={check.status} /> |
| 670 | return ( | 669 | <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span> |
| 671 | <div | 670 | <span className="shrink-0 text-muted-foreground"> |
| 672 | // Position too: the host decides how many runs share a name, and a repeated | 671 | {pullRequestCheckStatusLabel(check)} |
| 673 | // key would be a rendering fault on top of whatever the list already says. | 672 | </span> |
| 674 | key={`${index}:${check.name}:${check.url ?? ""}`} | 673 | </button> |
| 675 | className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60" | 674 | {/* Only where there is something to fix. A passing check has no failure to |
| 676 | > | | |
| 677 | <button | | |
| 678 | type="button" | | |
| 679 | disabled={!check.url} | | |
| 680 | onClick={() => check.url && openCheck(check.url)} | | |
| 681 | className={cn( | | |
| 682 | "flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-2 text-left text-xs leading-5 [&>svg]:mt-0.5", | | |
| 683 | check.url ? "cursor-pointer" : "cursor-default", | | |
| 684 | )} | | |
| 685 | > | | |
| 686 | <PullRequestCheckStatusIcon status={check.status} /> | | |
| 687 | <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span> | | |
| 688 | <span className="shrink-0 text-muted-foreground"> | | |
| 689 | {pullRequestCheckStatusLabel(check)} | | |
| 690 | </span> | | |
| 691 | </button> | | |
| 692 | {/* Only where there is something to fix. A passing check has no failure to | | |
| 693 | reproduce, and the button would be an invitation to waste a thread. */} | 675 | reproduce, and the button would be an invitation to waste a thread. */} |
| 694 | {onFixFinding && failing ? ( | 676 | {onFixFinding && failing ? ( |
| 695 | <Button | 677 | <Button |
| 696 | size="xs" | 678 | size="xs" |
| 697 | variant="ghost" | 679 | variant="ghost" |
| 698 | className="shrink-0" | 680 | className="shrink-0" |
| 699 | disabled={pendingFinding !== null && pendingFinding !== undefined} | 681 | disabled={pendingFinding !== null && pendingFinding !== undefined} |
| 700 | onClick={() => onFixFinding(finding)} | 682 | onClick={() => onFixFinding(finding)} |
| 701 | > | 683 | > |
| 702 | <HammerIcon className="size-3" /> | 684 | <HammerIcon className="size-3" /> |
| 703 | {pendingFinding === pullRequestFindingKey(finding) | 685 | {pendingFinding === pullRequestFindingKey(finding) |
| 704 | ? "Preparing..." | 686 | ? "Preparing..." |
| 705 | : fixCheckLabel} | 687 | : fixCheckLabel} |
| 706 | </Button> | 688 | </Button> |
| 707 | ) : null} | 689 | ) : null} |
| 708 | </div> | 690 | </div> |
| 709 | ); | 691 | ); |
| 710 | })} | 692 | }) |
| 711 | </div> | | |
| 712 | </div> | | |
| 713 | )} | 693 | )} |
| 714 | </section> | 694 | </Section> |
| 715 | | 695 | |
| 716 | <Section | 696 | <Section |
| 717 | title="Comments" | 697 | title="Comments" |