| ... | @@ -12,14 +12,13 @@ import { | ... | @@ -12,14 +12,13 @@ import { |
| 12 | ChevronRightIcon, | 12 | ChevronRightIcon, |
| 13 | GitPullRequestClosedIcon, | 13 | GitPullRequestClosedIcon, |
| 14 | HammerIcon, | 14 | HammerIcon, |
| 15 | MessageSquareIcon, | | |
| 16 | PencilIcon, | 15 | PencilIcon, |
| 17 | RotateCcwIcon, | 16 | RotateCcwIcon, |
| 18 | SendIcon, | 17 | SendIcon, |
| 19 | TagIcon, | 18 | TagIcon, |
| 20 | UsersIcon, | 19 | UsersIcon, |
| 21 | } from "lucide-react"; | 20 | } from "lucide-react"; |
| 22 | import { useRef, useState, type ReactNode } from "react"; | 21 | import { useId, useRef, useState, type ReactNode } from "react"; |
| 23 | | 22 | |
| 24 | import { useAtomCommand } from "~/state/use-atom-command"; | 23 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 24 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| ... | @@ -187,7 +186,7 @@ function CollapsedComment({ | ... | @@ -187,7 +186,7 @@ function CollapsedComment({ |
| 187 | <ChevronDownIcon | 186 | <ChevronDownIcon |
| 188 | aria-hidden | 187 | aria-hidden |
| 189 | className={cn( | 188 | className={cn( |
| 190 | "size-3.5 shrink-0 text-muted-foreground transition-transform", | 189 | "size-3.5 shrink-0 text-muted-foreground/60 transition-transform", |
| 191 | open && "rotate-180", | 190 | open && "rotate-180", |
| 192 | )} | 191 | )} |
| 193 | /> | 192 | /> |
| ... | @@ -229,8 +228,8 @@ function MetaRow({ | ... | @@ -229,8 +228,8 @@ function MetaRow({ |
| 229 | children: ReactNode; | 228 | children: ReactNode; |
| 230 | }) { | 229 | }) { |
| 231 | return ( | 230 | return ( |
| 232 | <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs"> | 231 | <div className="grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs"> |
| 233 | <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground"> | 232 | <span className="flex items-center gap-1.5 text-muted-foreground"> |
| 234 | {icon} | 233 | {icon} |
| 235 | {label} | 234 | {label} |
| 236 | </span> | 235 | </span> |
| ... | @@ -241,16 +240,13 @@ function MetaRow({ | ... | @@ -241,16 +240,13 @@ function MetaRow({ |
| 241 | | 240 | |
| 242 | function Section({ | 241 | function Section({ |
| 243 | title, | 242 | title, |
| 244 | count, | | |
| 245 | defaultOpen = true, | 243 | defaultOpen = true, |
| 246 | actions, | 244 | actions, |
| 247 | children, | 245 | children, |
| 248 | }: { | 246 | }: { |
| 249 | title: string; | 247 | title: string; |
| 250 | count?: number; | | |
| 251 | defaultOpen?: boolean; | 248 | defaultOpen?: boolean; |
| 252 | /** Controls riding on the heading row itself. A sibling of the trigger, not a child of it — | 249 | /** Heading controls stay separate from the collapse trigger so they remain independently usable. */ |
| 253 | a button cannot hold a button — and only while open, since they act on what is shown. */ | | |
| 254 | actions?: ReactNode; | 250 | actions?: ReactNode; |
| 255 | children: ReactNode; | 251 | children: ReactNode; |
| 256 | }) { | 252 | }) { |
| ... | @@ -286,24 +282,19 @@ function Section({ | ... | @@ -286,24 +282,19 @@ function Section({ |
| 286 | where it started. Opaque, because the rows it covers scroll beneath it. */} | 282 | where it started. Opaque, because the rows it covers scroll beneath it. */} |
| 287 | <div | 283 | <div |
| 288 | ref={headingRef} | 284 | ref={headingRef} |
| 289 | className="sticky top-0 z-10 flex w-full items-center border-t border-border/60 bg-background pr-4" | 285 | className="sticky top-0 z-10 flex w-full items-center bg-background pr-4" |
| 290 | > | 286 | > |
| 291 | {/* Title first, chevron riding to its right, count last: the row reads as a heading | 287 | <CollapsibleTrigger 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 hover:text-foreground"> |
| 292 | with an affordance rather than a tree node. */} | | |
| 293 | <CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-sm font-medium"> | | |
| 294 | <span>{title}</span> | 288 | <span>{title}</span> |
| 295 | <ChevronRightIcon | 289 | <ChevronRightIcon |
| 296 | aria-hidden | 290 | aria-hidden |
| 297 | className={cn( | 291 | className={cn( |
| 298 | "size-3.5 text-muted-foreground transition-transform", | 292 | "size-3.5 text-muted-foreground/60 transition-transform", |
| 299 | open && "rotate-90", | 293 | open && "rotate-90", |
| 300 | )} | 294 | )} |
| 301 | /> | 295 | /> |
| 302 | {count === undefined ? null : ( | | |
| 303 | <span className="text-xs tabular-nums text-muted-foreground">{count}</span> | | |
| 304 | )} | | |
| 305 | </CollapsibleTrigger> | 296 | </CollapsibleTrigger> |
| 306 | {open ? actions : null} | 297 | {actions} |
| 307 | </div> | 298 | </div> |
| 308 | <CollapsiblePanel> | 299 | <CollapsiblePanel> |
| 309 | <div className="px-4 pb-4">{children}</div> | 300 | <div className="px-4 pb-4">{children}</div> |
| ... | @@ -461,6 +452,9 @@ export function PullRequestSummaryTab({ | ... | @@ -461,6 +452,9 @@ export function PullRequestSummaryTab({ |
| 461 | // Keyed by the pull request, so opening another one starts at the end of its conversation | 452 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| 462 | // rather than wherever the last one had been read back to. | 453 | // rather than wherever the last one had been read back to. |
| 463 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); | 454 | const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); |
| | 455 | const checksId = useId(); |
| | 456 | const [expandedChecksUrl, setExpandedChecksUrl] = useState<string | null>(null); |
| | 457 | const showChecks = expandedChecksUrl === detail.url; |
| 464 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; | 458 | const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; |
| 465 | // Windowed by recency regardless of display order: expanding always reaches further back in | 459 | // Windowed by recency regardless of display order: expanding always reaches further back in |
| 466 | // time, whether the newest comment currently reads first or last. | 460 | // time, whether the newest comment currently reads first or last. |
| ... | @@ -596,8 +590,8 @@ export function PullRequestSummaryTab({ | ... | @@ -596,8 +590,8 @@ export function PullRequestSummaryTab({ |
| 596 | | 590 | |
| 597 | return ( | 591 | return ( |
| 598 | <div className="h-full overflow-y-auto" data-pull-request-summary-scroll> | 592 | <div className="h-full overflow-y-auto" data-pull-request-summary-scroll> |
| 599 | <section className="px-4 py-3"> | 593 | <section className="px-4 py-2.5"> |
| 600 | <div> | 594 | <div className="space-y-2"> |
| 601 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> | 595 | <MetaRow icon={<UsersIcon className="size-3.5" />} label="Reviewers"> |
| 602 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> | 596 | <span className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 603 | {reviewerEntries.length === 0 ? ( | 597 | {reviewerEntries.length === 0 ? ( |
| ... | @@ -700,7 +694,7 @@ export function PullRequestSummaryTab({ | ... | @@ -700,7 +694,7 @@ export function PullRequestSummaryTab({ |
| 700 | return ( | 694 | return ( |
| 701 | <span | 695 | <span |
| 702 | key={label.name} | 696 | key={label.name} |
| 703 | className="inline-flex max-w-48 items-center gap-1.5 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" | 697 | className="inline-flex max-w-48 items-center gap-1.5 rounded-full bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs" |
| 704 | > | 698 | > |
| 705 | <span | 699 | <span |
| 706 | aria-hidden | 700 | aria-hidden |
| ... | @@ -722,19 +716,10 @@ export function PullRequestSummaryTab({ | ... | @@ -722,19 +716,10 @@ export function PullRequestSummaryTab({ |
| 722 | </span> | 716 | </span> |
| 723 | </MetaRow> | 717 | </MetaRow> |
| 724 | ) : null} | 718 | ) : null} |
| 725 | <MetaRow icon={<MessageSquareIcon className="size-3.5" />} label="Comments"> | | |
| 726 | {activityPending | | |
| 727 | ? "Loading conversation…" | | |
| 728 | : activityError | | |
| 729 | ? "Conversation unavailable" | | |
| 730 | : detail.commentCount === 1 | | |
| 731 | ? "1 comment" | | |
| 732 | : `${detail.commentCount} comments`} | | |
| 733 | </MetaRow> | | |
| 734 | </div> | 719 | </div> |
| 735 | </section> | 720 | </section> |
| 736 | | 721 | |
| 737 | <Section title="Description"> | 722 | <section aria-label="Description" className="px-4 pt-2 pb-4"> |
| 738 | <div className="group"> | 723 | <div className="group"> |
| 739 | {bodyScope === detail.url ? ( | 724 | {bodyScope === detail.url ? ( |
| 740 | <PullRequestMarkdownEditor | 725 | <PullRequestMarkdownEditor |
| ... | @@ -781,81 +766,96 @@ export function PullRequestSummaryTab({ | ... | @@ -781,81 +766,96 @@ export function PullRequestSummaryTab({ |
| 781 | onRefresh={onRefresh} | 766 | onRefresh={onRefresh} |
| 782 | /> | 767 | /> |
| 783 | </div> | 768 | </div> |
| 784 | </Section> | 769 | </section> |
| 785 | | 770 | |
| 786 | <Section title="Checks" count={detail.checks.length}> | 771 | <section aria-label="Checks" className="px-4 py-3"> |
| 787 | {detail.checks.length === 0 ? ( | 772 | {detail.checks.length === 0 ? ( |
| 788 | <p className="text-xs text-muted-foreground">No checks reported.</p> | 773 | <p className="text-xs text-muted-foreground">No checks reported.</p> |
| 789 | ) : ( | 774 | ) : ( |
| 790 | <div className="space-y-0.5"> | 775 | <div> |
| 791 | {detail.checks.map((check, index) => { | 776 | <div className="flex items-center gap-1 text-xs"> |
| 792 | const finding = { kind: "check", check } as const; | 777 | <span className="font-medium text-muted-foreground">Checks</span> |
| 793 | const failing = check.status === "failure" || check.status === "cancelled"; | 778 | <Button |
| 794 | return ( | 779 | size="icon-xs" |
| 795 | <div | 780 | variant="ghost-muted" |
| 796 | // Position too: the host decides how many runs share a name, and a repeated | 781 | aria-label={showChecks ? "Hide checks" : "Show checks"} |
| 797 | // key would be a rendering fault on top of whatever the list already says. | 782 | aria-expanded={showChecks} |
| 798 | key={`${index}:${check.name}:${check.url ?? ""}`} | 783 | aria-controls={checksId} |
| 799 | className="group flex items-center gap-1 rounded-md pr-1 hover:bg-accent/60" | 784 | onClick={() => setExpandedChecksUrl(showChecks ? null : detail.url)} |
| 800 | > | 785 | > |
| 801 | <button | 786 | <ChevronRightIcon |
| 802 | type="button" | 787 | aria-hidden |
| 803 | disabled={!check.url} | 788 | className={cn("size-3.5 text-muted-foreground/60", showChecks && "rotate-90")} |
| 804 | onClick={() => check.url && openCheck(check.url)} | 789 | /> |
| 805 | className={cn( | 790 | </Button> |
| 806 | "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs", | 791 | </div> |
| 807 | check.url ? undefined : "cursor-default", | 792 | <div id={checksId} className={showChecks ? "mt-2" : "hidden"}> |
| 808 | )} | 793 | {(showChecks ? detail.checks : []).map((check, index) => { |
| | 794 | const finding = { kind: "check", check } as const; |
| | 795 | const failing = check.status === "failure" || check.status === "cancelled"; |
| | 796 | return ( |
| | 797 | <div |
| | 798 | // Position too: the host decides how many runs share a name, and a repeated |
| | 799 | // key would be a rendering fault on top of whatever the list already says. |
| | 800 | key={`${index}:${check.name}:${check.url ?? ""}`} |
| | 801 | className="group flex items-center gap-2 rounded-md pr-1 hover:bg-accent/60" |
| 809 | > | 802 | > |
| 810 | <PullRequestCheckStatusIcon status={check.status} /> | 803 | <button |
| 811 | <span className="min-w-0 flex-1 truncate">{check.name}</span> | 804 | type="button" |
| 812 | <span className="shrink-0 text-muted-foreground"> | 805 | disabled={!check.url} |
| 813 | {pullRequestCheckStatusLabel(check)} | 806 | onClick={() => check.url && openCheck(check.url)} |
| 814 | </span> | 807 | className={cn( |
| 815 | </button> | 808 | "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", |
| 816 | {/* Only where there is something to fix. A passing check has no failure to | 809 | check.url ? undefined : "cursor-default", |
| 817 | reproduce, and the button would be an invitation to waste a thread. */} | 810 | )} |
| 818 | {onFixFinding && failing ? ( | | |
| 819 | <Button | | |
| 820 | size="xs" | | |
| 821 | variant="ghost" | | |
| 822 | className="shrink-0" | | |
| 823 | disabled={pendingFinding !== null && pendingFinding !== undefined} | | |
| 824 | onClick={() => onFixFinding(finding)} | | |
| 825 | > | 811 | > |
| 826 | <HammerIcon className="size-3" /> | 812 | <PullRequestCheckStatusIcon status={check.status} /> |
| 827 | {pendingFinding === pullRequestFindingKey(finding) | 813 | <span className="min-w-0 flex-1 wrap-anywhere">{check.name}</span> |
| 828 | ? "Preparing..." | 814 | <span className="shrink-0 text-muted-foreground"> |
| 829 | : fixCheckLabel} | 815 | {pullRequestCheckStatusLabel(check)} |
| 830 | </Button> | 816 | </span> |
| 831 | ) : null} | 817 | </button> |
| 832 | </div> | 818 | {/* Only where there is something to fix. A passing check has no failure to |
| 833 | ); | 819 | reproduce, and the button would be an invitation to waste a thread. */} |
| 834 | })} | 820 | {onFixFinding && failing ? ( |
| | 821 | <Button |
| | 822 | size="xs" |
| | 823 | variant="ghost" |
| | 824 | className="shrink-0" |
| | 825 | disabled={pendingFinding !== null && pendingFinding !== undefined} |
| | 826 | onClick={() => onFixFinding(finding)} |
| | 827 | > |
| | 828 | <HammerIcon className="size-3" /> |
| | 829 | {pendingFinding === pullRequestFindingKey(finding) |
| | 830 | ? "Preparing..." |
| | 831 | : fixCheckLabel} |
| | 832 | </Button> |
| | 833 | ) : null} |
| | 834 | </div> |
| | 835 | ); |
| | 836 | })} |
| | 837 | </div> |
| 835 | </div> | 838 | </div> |
| 836 | )} | 839 | )} |
| 837 | </Section> | 840 | </section> |
| 838 | | 841 | |
| 839 | <Section | 842 | <Section |
| 840 | title="Comments" | 843 | title="Comments" |
| 841 | {...(activityPending || activityError ? {} : { count: detail.commentCount })} | | |
| 842 | actions={ | 844 | actions={ |
| 843 | !activityPending && !activityError && detail.comments.length > 0 ? ( | 845 | <Button |
| 844 | <Button | 846 | size="xs" |
| 845 | size="xs" | 847 | variant="ghost" |
| 846 | variant="ghost" | 848 | className="h-7 shrink-0 px-2 text-[10px] text-muted-foreground" |
| 847 | className="h-7 shrink-0 px-2 text-[10px] text-muted-foreground" | 849 | aria-label={ |
| 848 | aria-label={ | 850 | commentOrder === "newest" |
| 849 | commentOrder === "newest" | 851 | ? "Show oldest comments first" |
| 850 | ? "Show oldest comments first" | 852 | : "Show newest comments first" |
| 851 | : "Show newest comments first" | 853 | } |
| 852 | } | 854 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} |
| 853 | onClick={() => setCommentOrder((value) => (value === "newest" ? "oldest" : "newest"))} | 855 | > |
| 854 | > | 856 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 855 | <ArrowDownUpIcon aria-hidden className="size-3" /> | 857 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} |
| 856 | {commentOrder === "newest" ? "Newest first" : "Oldest first"} | 858 | </Button> |
| 857 | </Button> | | |
| 858 | ) : null | | |
| 859 | } | 859 | } |
| 860 | > | 860 | > |
| 861 | {activityPending ? ( | 861 | {activityPending ? ( |