293 lines · 11.0 KB · TypeScript
RawDownload
import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";import {  resolveThreadPullRequestChains,  visibleThreadPullRequests,} from "@t3tools/shared/threadPullRequests";import {  GitPullRequestArrow,  LayersIcon,  LinkIcon,  MoreHorizontalIcon,  PlusIcon,} from "lucide-react";import { useCallback, useMemo } from "react"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { useOpenPrLink } from "~/lib/openPullRequestLink";import { cn } from "~/lib/utils";import { useServerConfigs, useThreadShell } from "~/state/entities";import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";import { threadEnvironment } from "~/state/threads";import { useAtomCommand } from "~/state/use-atom-command";import { formatRelativeTimeLabel } from "~/timestampFormat";import { Button } from "../ui/button";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";import { ScrollArea } from "../ui/scroll-area";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";import {  PullRequestActorAvatar,  PullRequestDiffStat,  PullRequestApprovalGlyph,  PullRequestStateGlyph,  pullRequestChecksStatePresentation,} from "./pullRequestPresentation"; const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {  manual: "Linked by you",  created: "Created from this thread",  agent: "Linked by the agent",  stack: "Found in the stack",  "stack-dismissed": "Dismissed",}; function ChecksGlyph({  state,}: {  state: NonNullable<ThreadPullRequestLink["snapshot"]>["checksState"] & string;}) {  const presentation = pullRequestChecksStatePresentation(state);  return (    <Tooltip>      <TooltipTrigger render={<span className="inline-flex shrink-0" />}>        <presentation.Icon          role="img"          aria-label={presentation.label}          className={cn("size-3.5", presentation.toneClassName)}        />      </TooltipTrigger>      <TooltipPopup>{presentation.label}</TooltipPopup>    </Tooltip>  );} function LinkRow({  line,  threadRef,  onUnlink,}: {  line: PullRequestListLine;  threadRef: ScopedThreadRef;  onUnlink: (link: ThreadPullRequestLink) => void;}) {  const openPrLink = useOpenPrLink(threadRef);  const { link, depth, stack } = line;  const snapshot = link.snapshot;  return (    <div      className="group/pr-row flex items-center gap-2 rounded-md py-1 pr-1 hover:bg-accent/60"      // Each layer steps in under the one it targets. The step is capped: beyond a few layers      // the indent only says "still in the stack", which the connector line already does, and      // a sixteen-layer stack would otherwise stair-step off the right edge.      style={{ paddingLeft: `${0.5 + Math.min(depth, 3) * 1.25}rem` }}    >      {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}      {snapshot === null ? (        <GitPullRequestArrow          aria-label="Waiting for host state"          className="size-4 shrink-0 text-muted-foreground"        />      ) : (        <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />      )}      <a        href={link.url}        onClick={(event) => openPrLink(event, link.url, threadRef)}        className="min-w-0 flex-1"      >        <span className="flex min-w-0 items-center gap-1.5">          <Tooltip>            <TooltipTrigger              render={                <span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground" />              }            >              #{link.number}            </TooltipTrigger>            <TooltipPopup>              {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)}            </TooltipPopup>          </Tooltip>          <span className="min-w-0 flex-1 truncate text-sm">            {snapshot?.title ?? link.repository}          </span>          {/* Match the full PR list: review verdict, checks, then diff counts.              Each is absent rather than neutral when the              host said nothing, so a row without them reads as unknown, not as fine. */}          <span className="ml-auto flex shrink-0 items-center gap-1.5 text-[11px]">            {snapshot?.state === "open" &&            (snapshot.reviewDecision === "approved" ||              snapshot.reviewDecision === "changes-requested") ? (              snapshot.reviewDecision === "approved" ? (                <PullRequestApprovalGlyph />              ) : (                <span className="text-amber-600/90 dark:text-amber-400/80">Changes requested</span>              )            ) : null}            {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? (              <span className="text-destructive">Conflicts</span>            ) : null}            {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}            <PullRequestDiffStat              additions={snapshot?.additions ?? 0}              deletions={snapshot?.deletions ?? 0}              className="font-mono"            />          </span>        </span>        <span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">          {stack ? (            <Tooltip>              <TooltipTrigger                render={                  <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />                }              >                {stack.kind === "native" ? (                  <LayersIcon aria-hidden className="size-3" />                ) : (                  <GitPullRequestArrow aria-hidden className="size-3" />                )}                {stack.size}              </TooltipTrigger>              <TooltipPopup>                {stack.kind === "native"                  ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.`                  : `${stack.size} pull requests chained by base branch.`}              </TooltipPopup>            </Tooltip>          ) : null}          {snapshot?.author ? (            <span className="inline-flex shrink-0 items-center gap-1">              <PullRequestActorAvatar actor={snapshot.author} className="size-3.5" />              <span className="max-w-28 truncate">{snapshot.author.login}</span>            </span>          ) : null}          <span className="truncate font-mono">            {snapshot !== null              ? `${snapshot.headBranch} → ${snapshot.baseBranch}`              : `${link.host}/${link.repository}`}          </span>          {snapshot?.updatedAt ? (            <span className="ml-auto shrink-0">{formatRelativeTimeLabel(snapshot.updatedAt)}</span>          ) : null}        </span>      </a>      <Menu>        <MenuTrigger          render={            <Button              variant="ghost"              size="icon-xs"              aria-label={`Actions for #${link.number}`}              className={cn(                "opacity-0 group-hover/pr-row:opacity-100 data-[popup-open]:opacity-100",              )}            >              <MoreHorizontalIcon className="size-3.5" />            </Button>          }        />        <MenuPopup align="end" side="bottom">          <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>Copy link</MenuItem>          <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>Open</MenuItem>          <MenuItem onClick={() => onUnlink(link)}>            {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}          </MenuItem>        </MenuPopup>      </Menu>    </div>  );} export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {  const configs = useServerConfigs();  if (configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true) {    return (      <PullRequestsUnavailableState        title="Linked pull requests unavailable"        error="This environment does not support multiple linked pull requests."      />    );  }  return <EnabledThreadPullRequestsPanel threadRef={threadRef} />;} function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {  const thread = useThreadShell(threadRef);  const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);  const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });  const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);  const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);  const handleUnlink = useCallback(    (link: ThreadPullRequestLink) => {      void unlink({        environmentId: threadRef.environmentId,        input: {          threadId: threadRef.threadId,          host: link.host,          repository: link.repository,          number: link.number,        },      });    },    [threadRef, unlink],  );  const openCount = useMemo(    () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length,    [links],  );  const lastSynced = useMemo(() => {    let latest: string | null = null;    for (const link of links) {      const at = link.snapshot?.syncedAt;      if (at !== undefined && (latest === null || at > latest)) latest = at;    }    return latest;  }, [links]);   if (links.length === 0) {    return (      <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">        <LinkIcon aria-hidden className="size-6 text-muted-foreground/60" />        <p className="text-sm font-medium">No linked pull requests</p>        <p className="max-w-60 text-xs text-muted-foreground">          Pull requests the agent opens from this thread land here. Link one yourself from a URL or          a number.        </p>        <Button size="sm" variant="outline" onClick={openLinkDialog}>          <PlusIcon className="size-3.5" />          Link pull request        </Button>      </div>    );  }   return (    <div className="flex h-full min-h-0 flex-col">      <ScrollArea className="min-h-0 flex-1">        <div className="flex flex-col p-1.5">          {lines.map((line) => (            <LinkRow              key={`${line.link.host}/${line.link.repository}#${line.link.number}`}              line={line}              threadRef={threadRef}              onUnlink={handleUnlink}            />          ))}        </div>      </ScrollArea>      <footer className="flex items-center justify-between border-t border-border/60 px-2 py-1.5 text-[.7rem] text-muted-foreground">        <span>          {openCount} open · {links.length} linked          {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""}        </span>        <Button size="xs" variant="ghost" onClick={openLinkDialog}>          <PlusIcon className="size-3.5" />          Link        </Button>      </footer>    </div>  );}