diff --git a/apps/dashboard/src/components/details/detail-activity.tsx b/apps/dashboard/src/components/details/detail-activity.tsx
new file mode 100644
index 0000000..7980900
--- /dev/null
+++ b/apps/dashboard/src/components/details/detail-activity.tsx
@@ -0,0 +1,45 @@
+import { useState } from "react";
+
+export function DetailActivityHeader({
+ title,
+ count,
+}: {
+ title: string;
+ count?: number;
+}) {
+ return (
+
+
{title}
+ {count != null && (
+
+ {count}
+
+ )}
+
+ );
+}
+
+export function DetailCommentBox() {
+ const [value, setValue] = useState("");
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/details/detail-page.tsx b/apps/dashboard/src/components/details/detail-page.tsx
new file mode 100644
index 0000000..d1792a9
--- /dev/null
+++ b/apps/dashboard/src/components/details/detail-page.tsx
@@ -0,0 +1,107 @@
+import { Skeleton } from "@diffkit/ui/components/skeleton";
+import { cn } from "@diffkit/ui/lib/utils";
+import { Link } from "@tanstack/react-router";
+
+type DetailHeaderIcon = React.ComponentType<{
+ size?: number;
+ strokeWidth?: number;
+ className?: string;
+}>;
+
+export function DetailPageLayout({
+ main,
+ sidebar,
+}: {
+ main: React.ReactNode;
+ sidebar: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function DetailPageTitle({
+ collectionHref,
+ collectionLabel,
+ owner,
+ repo,
+ number,
+ icon: Icon,
+ iconClassName,
+ title,
+ subtitle,
+}: {
+ collectionHref: string;
+ collectionLabel: string;
+ owner: string;
+ repo: string;
+ number: number;
+ icon: DetailHeaderIcon;
+ iconClassName?: string;
+ title: string;
+ subtitle: React.ReactNode;
+}) {
+ return (
+
+
+
+ {collectionLabel}
+
+ /
+
+ {owner}/{repo}
+
+ /
+ #{number}
+
+
+
+
+
+
+
+
{title}
+ {subtitle}
+
+
+
+ );
+}
+
+export function DetailPageSkeletonLayout({
+ main,
+ sidebarSectionCount = 4,
+}: {
+ main: React.ReactNode;
+ sidebarSectionCount?: number;
+}) {
+ return (
+
+
+
{main}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/details/detail-sidebar.tsx b/apps/dashboard/src/components/details/detail-sidebar.tsx
new file mode 100644
index 0000000..f56e19f
--- /dev/null
+++ b/apps/dashboard/src/components/details/detail-sidebar.tsx
@@ -0,0 +1,83 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@diffkit/ui/components/tooltip";
+
+type DetailRowIcon = React.ComponentType<{
+ size?: number;
+ strokeWidth?: number;
+ className?: string;
+}>;
+
+export function DetailSidebar({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+export function DetailSidebarSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+export function DetailSidebarRow({
+ icon: Icon,
+ label,
+ children,
+}: {
+ icon?: DetailRowIcon;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {Icon ? : null}
+ {label}
+
+ {children}
+
+ );
+}
+
+export function DetailParticipantAvatars({
+ actors,
+}: {
+ actors: Array<{
+ login: string;
+ avatarUrl: string;
+ }>;
+}) {
+ return (
+
+ {actors.map((actor, index) => (
+
+
+
0 ? { marginLeft: -6 } : undefined}
+ />
+
+ {actor.login}
+
+ ))}
+
+ );
+}
diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx
new file mode 100644
index 0000000..a9c283d
--- /dev/null
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx
@@ -0,0 +1,88 @@
+import { Markdown } from "@diffkit/ui/components/markdown";
+import { cn } from "@diffkit/ui/lib/utils";
+import {
+ DetailActivityHeader,
+ DetailCommentBox,
+} from "#/components/details/detail-activity";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import type { IssueComment } from "#/lib/github.types";
+
+export function IssueDetailActivitySection({
+ comments,
+ isFetching,
+}: {
+ comments?: IssueComment[];
+ isFetching: boolean;
+}) {
+ return (
+
+
+
+ {isFetching && !comments && (
+
+ )}
+
+ {comments && comments.length === 0 && (
+
No comments yet.
+ )}
+
+ {comments && comments.length > 0 && (
+
+ {comments.map((comment, index) => (
+
+
+ {comment.author ? (
+

+ ) : (
+
+ )}
+
+ {comment.author?.login ?? "Unknown"}
+
+
+ {formatRelativeTime(comment.createdAt)}
+
+
+
+ {comment.body}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx
new file mode 100644
index 0000000..ff56f3d
--- /dev/null
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx
@@ -0,0 +1,98 @@
+import { IssuesIcon } from "@diffkit/icons";
+import { Markdown } from "@diffkit/ui/components/markdown";
+import { cn } from "@diffkit/ui/lib/utils";
+import { DetailPageTitle } from "#/components/details/detail-page";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import type { IssueDetail } from "#/lib/github.types";
+
+type IssueStateConfig = {
+ color: string;
+ label: string;
+ badgeClass: string;
+};
+
+export function getIssueStateConfig(issue: IssueDetail): IssueStateConfig {
+ if (issue.state === "closed") {
+ if (issue.stateReason === "not_planned") {
+ return {
+ color: "text-muted-foreground",
+ label: "Closed",
+ badgeClass: "bg-muted text-muted-foreground",
+ };
+ }
+ return {
+ color: "text-purple-500",
+ label: "Closed",
+ badgeClass: "bg-purple-500/10 text-purple-500",
+ };
+ }
+ return {
+ color: "text-green-500",
+ label: "Open",
+ badgeClass: "bg-green-500/10 text-green-500",
+ };
+}
+
+export function IssueDetailHeader({
+ owner,
+ repo,
+ issue,
+}: {
+ owner: string;
+ repo: string;
+ issue: IssueDetail;
+}) {
+ const stateConfig = getIssueStateConfig(issue);
+
+ return (
+ <>
+
+
+ {stateConfig.label}
+
+ {issue.author && (
+
+
+
+ {issue.author.login}
+
+ opened {formatRelativeTime(issue.createdAt)}
+
+ )}
+
+ }
+ />
+
+ {issue.body ? (
+
+ {issue.body}
+
+ ) : (
+
+
+ No description provided.
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx
new file mode 100644
index 0000000..43684ed
--- /dev/null
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx
@@ -0,0 +1,129 @@
+import { Skeleton } from "@diffkit/ui/components/skeleton";
+import { useQuery } from "@tanstack/react-query";
+import { getRouteApi } from "@tanstack/react-router";
+import {
+ DetailPageLayout,
+ DetailPageSkeletonLayout,
+} from "#/components/details/detail-page";
+import { githubIssuePageQueryOptions } from "#/lib/github.query";
+import { useHasMounted } from "#/lib/use-has-mounted";
+import { useRegisterTab } from "#/lib/use-register-tab";
+import { IssueDetailActivitySection } from "./issue-detail-activity";
+import { getIssueStateConfig, IssueDetailHeader } from "./issue-detail-header";
+import { IssueDetailSidebar } from "./issue-detail-sidebar";
+
+const routeApi = getRouteApi("/_protected/$owner/$repo/issues/$issueId");
+
+export function IssueDetailPage() {
+ const { user } = routeApi.useRouteContext();
+ const { owner, repo, issueId } = routeApi.useParams();
+ const issueNumber = Number(issueId);
+ const scope = { userId: user.id };
+ const hasMounted = useHasMounted();
+
+ const pageQuery = useQuery({
+ ...githubIssuePageQueryOptions(scope, { owner, repo, issueNumber }),
+ enabled: hasMounted,
+ });
+
+ const issue = pageQuery.data?.detail;
+ const comments = pageQuery.data?.comments;
+
+ useRegisterTab(
+ issue
+ ? {
+ type: "issue",
+ title: issue.title,
+ number: issue.number,
+ url: `/${owner}/${repo}/issues/${issueId}`,
+ repo: `${owner}/${repo}`,
+ iconColor: getIssueStateConfig(issue).color,
+ }
+ : null,
+ );
+
+ if (pageQuery.error) throw pageQuery.error;
+ if (!issue) return ;
+
+ return (
+
+
+
+ >
+ }
+ sidebar={
+
+ }
+ />
+ );
+}
+
+function IssueDetailPageSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {["activity-1", "activity-2", "activity-3"].map((key) => (
+
+ ))}
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-sidebar.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-sidebar.tsx
new file mode 100644
index 0000000..0799e53
--- /dev/null
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-sidebar.tsx
@@ -0,0 +1,136 @@
+import {
+ DetailParticipantAvatars,
+ DetailSidebar,
+ DetailSidebarRow,
+ DetailSidebarSection,
+} from "#/components/details/detail-sidebar";
+import { LabelsSection } from "#/components/issues/labels-section";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import { type GitHubQueryScope, githubQueryKeys } from "#/lib/github.query";
+import type {
+ GitHubActor,
+ IssueComment,
+ IssueDetail,
+} from "#/lib/github.types";
+
+export function IssueDetailSidebar({
+ issue,
+ owner,
+ repo,
+ issueNumber,
+ scope,
+ comments,
+}: {
+ issue: IssueDetail;
+ owner: string;
+ repo: string;
+ issueNumber: number;
+ scope: GitHubQueryScope;
+ comments: IssueComment[];
+}) {
+ return (
+
+
+ {issue.assignees.length > 0 ? (
+
+ {issue.assignees.map((assignee) => (
+
+

+
{assignee.login}
+
+ ))}
+
+ ) : (
+ No one assigned
+ )}
+
+
+
+
+
+
+
+
+ {issue.milestone && (
+
+
+
+ {issue.milestone.title}
+
+ {issue.milestone.dueOn && (
+
+ Due {formatRelativeTime(issue.milestone.dueOn)}
+
+ )}
+
+
+ )}
+
+
+
+
+ {formatRelativeTime(issue.createdAt)}
+
+
+ {formatRelativeTime(issue.updatedAt)}
+
+ {issue.closedAt && (
+
+ {formatRelativeTime(issue.closedAt)}
+
+ )}
+
+ {issue.comments}
+
+
+
+
+ );
+}
+
+function ParticipantsList({
+ issue,
+ comments,
+}: {
+ issue: IssueDetail;
+ comments: IssueComment[];
+}) {
+ const seen = new Set();
+ const participants: GitHubActor[] = [];
+
+ const addActor = (actor: GitHubActor | null) => {
+ if (actor && !seen.has(actor.login)) {
+ seen.add(actor.login);
+ participants.push(actor);
+ }
+ };
+
+ addActor(issue.author);
+ for (const assignee of issue.assignees) {
+ addActor(assignee);
+ }
+ for (const comment of comments) {
+ addActor(comment.author);
+ }
+
+ if (participants.length === 0) {
+ return No participants yet
;
+ }
+
+ return ;
+}
diff --git a/apps/dashboard/src/components/issues/issue-row.tsx b/apps/dashboard/src/components/issues/issue-row.tsx
index 0a06301..f1becfd 100644
--- a/apps/dashboard/src/components/issues/issue-row.tsx
+++ b/apps/dashboard/src/components/issues/issue-row.tsx
@@ -1,7 +1,7 @@
import { CommentIcon, IssuesIcon } from "@diffkit/icons";
import { cn } from "@diffkit/ui/lib/utils";
import { Link } from "@tanstack/react-router";
-import { formatRelativeTime } from "#/components/pulls/pull-request-row";
+import { formatRelativeTime } from "#/lib/format-relative-time";
import type { IssueSummary } from "#/lib/github.types";
function getIssueStateProps(issue: IssueSummary) {
diff --git a/apps/dashboard/src/components/labels-section.tsx b/apps/dashboard/src/components/issues/labels-section.tsx
similarity index 100%
rename from apps/dashboard/src/components/labels-section.tsx
rename to apps/dashboard/src/components/issues/labels-section.tsx
diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx
index 4a4fd2b..13645ae 100644
--- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx
+++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, Outlet } from "@tanstack/react-router";
-import { CommandPalette } from "#/components/command-palette";
+import { CommandPalette } from "#/components/navigation/command-palette";
import {
githubMyIssuesQueryOptions,
githubMyPullsQueryOptions,
diff --git a/apps/dashboard/src/components/command-palette.tsx b/apps/dashboard/src/components/navigation/command-palette.tsx
similarity index 97%
rename from apps/dashboard/src/components/command-palette.tsx
rename to apps/dashboard/src/components/navigation/command-palette.tsx
index a379828..ffcdbb4 100644
--- a/apps/dashboard/src/components/command-palette.tsx
+++ b/apps/dashboard/src/components/navigation/command-palette.tsx
@@ -10,10 +10,10 @@ import {
} from "@diffkit/ui/components/command";
import { cn } from "@diffkit/ui/lib/utils";
import { useRouter } from "@tanstack/react-router";
-import { formatRelativeTime } from "#/components/pulls/pull-request-row";
import type { CommandItem, CommandItemMeta } from "#/lib/command-palette/types";
import { useCommandItems } from "#/lib/command-palette/use-command-items";
import { useCommandPalette } from "#/lib/command-palette/use-command-palette";
+import { formatRelativeTime } from "#/lib/format-relative-time";
export function CommandPalette() {
const { open, setOpen, close } = useCommandPalette();
diff --git a/apps/dashboard/src/components/pulls/detail/pull-body-section.tsx b/apps/dashboard/src/components/pulls/detail/pull-body-section.tsx
new file mode 100644
index 0000000..2fa9636
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/detail/pull-body-section.tsx
@@ -0,0 +1,447 @@
+import {
+ CheckIcon,
+ CopyIcon,
+ EditIcon,
+ MoreHorizontalIcon,
+} from "@diffkit/icons";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@diffkit/ui/components/dropdown-menu";
+import { highlightCode, Markdown } from "@diffkit/ui/components/markdown";
+import { Spinner } from "@diffkit/ui/components/spinner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@diffkit/ui/components/tooltip";
+import { cn } from "@diffkit/ui/lib/utils";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { updatePullBody } from "#/lib/github.functions";
+import { type GitHubQueryScope, githubQueryKeys } from "#/lib/github.query";
+import type { PullDetail, PullPageData } from "#/lib/github.types";
+import { useOptimisticMutation } from "#/lib/use-optimistic-mutation";
+
+export function PullBodySection({
+ pr,
+ owner,
+ repo,
+ pullNumber,
+ isAuthor,
+ scope,
+}: {
+ pr: PullDetail;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ isAuthor: boolean;
+ scope: GitHubQueryScope;
+}) {
+ const { mutate } = useOptimisticMutation();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editTab, setEditTab] = useState<"edit" | "preview">("edit");
+ const [draft, setDraft] = useState(pr.body);
+ const [isSaving, setIsSaving] = useState(false);
+ const editorRef = useRef(null);
+
+ const insertMarkdown = useCallback(
+ (before: string, after = "", placeholder = "") => {
+ const textarea = editorRef.current;
+ if (!textarea) return;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const selected = draft.slice(start, end);
+ const text = selected || placeholder;
+ const newValue = `${draft.slice(0, start)}${before}${text}${after}${draft.slice(end)}`;
+ setDraft(newValue);
+ requestAnimationFrame(() => {
+ textarea.focus();
+ const cursorStart = start + before.length;
+ textarea.setSelectionRange(cursorStart, cursorStart + text.length);
+ });
+ },
+ [draft],
+ );
+
+ const handleEditorKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ const mod = event.metaKey || event.ctrlKey;
+ if (!mod) return;
+
+ const shortcuts: Record void> = {
+ b: () => insertMarkdown("**", "**", "bold"),
+ i: () => insertMarkdown("_", "_", "italic"),
+ e: () => insertMarkdown("`", "`", "code"),
+ k: () => insertMarkdown("[", "](url)", "text"),
+ h: () => insertMarkdown("### ", "", "heading"),
+ };
+
+ if (event.shiftKey) {
+ const shiftShortcuts: Record void> = {
+ ".": () => insertMarkdown("> ", "", "quote"),
+ "8": () => insertMarkdown("- ", "", "item"),
+ "7": () => insertMarkdown("1. ", "", "item"),
+ };
+ const action = shiftShortcuts[event.key];
+ if (action) {
+ event.preventDefault();
+ action();
+ }
+ return;
+ }
+
+ const action = shortcuts[event.key];
+ if (action) {
+ event.preventDefault();
+ action();
+ }
+ },
+ [insertMarkdown],
+ );
+
+ const pageQueryKey = githubQueryKeys.pulls.page(scope, {
+ owner,
+ repo,
+ pullNumber,
+ });
+
+ const startEditing = () => {
+ setDraft(pr.body);
+ setEditTab("edit");
+ setIsEditing(true);
+ };
+
+ const saveBody = async () => {
+ setIsSaving(true);
+ try {
+ await mutate({
+ mutationFn: () =>
+ updatePullBody({
+ data: { owner, repo, pullNumber, body: draft },
+ }),
+ updates: [
+ {
+ queryKey: pageQueryKey,
+ updater: (prev: PullPageData) => ({
+ ...prev,
+ detail: prev.detail
+ ? { ...prev.detail, body: draft }
+ : prev.detail,
+ }),
+ },
+ ],
+ });
+ setIsEditing(false);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (isEditing) {
+ return (
+
+
+
+
+
+
+ {editTab === "edit" && (
+
+
+
insertMarkdown("### ", "", "heading")}
+ >
+
+
+
insertMarkdown("**", "**", "bold")}
+ >
+
+
+
insertMarkdown("_", "_", "italic")}
+ >
+
+
+
+
insertMarkdown("`", "`", "code")}
+ >
+
+
+
insertMarkdown("[", "](url)", "text")}
+ >
+
+
+
+
insertMarkdown("> ", "", "quote")}
+ >
+
+
+
+
+
insertMarkdown("- ", "", "item")}
+ >
+
+
+
insertMarkdown("1. ", "", "item")}
+ >
+
+
+
insertMarkdown("- [ ] ", "", "task")}
+ >
+
+
+
+
+
+ )}
+
+
+ {editTab === "edit" ? (
+
+ ) : (
+
+ {draft ? (
+
{draft}
+ ) : (
+
+ Nothing to preview
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {pr.body && (
+ {
+ void navigator.clipboard.writeText(pr.body);
+ }}
+ >
+
+ Copy as Markdown
+
+ )}
+ {isAuthor && (
+
+
+ Edit
+
+ )}
+
+
+ {pr.body ? (
+
{pr.body}
+ ) : (
+
+ No description provided.
+
+ )}
+
+ );
+}
+
+function MdToolbarButton({
+ label,
+ shortcut,
+ onClick,
+ children,
+}: {
+ label: string;
+ shortcut?: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+ {label}
+ {shortcut && (
+
+ {shortcut}
+
+ )}
+
+
+
+ );
+}
+
+function HighlightedMarkdownEditor({
+ value,
+ onChange,
+ placeholder,
+ textareaRef: externalRef,
+ onKeyDown,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ textareaRef?: React.RefObject;
+ onKeyDown?: React.KeyboardEventHandler;
+}) {
+ const [highlightedHtml, setHighlightedHtml] = useState("");
+ const internalRef = useRef(null);
+ const textareaRef = externalRef || internalRef;
+ const highlightRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ if (!value) {
+ setHighlightedHtml("");
+ return;
+ }
+ highlightCode(value, "markdown").then((html) => {
+ if (!cancelled) {
+ setHighlightedHtml(html);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [value]);
+
+ const syncScroll = () => {
+ if (highlightRef.current && textareaRef.current) {
+ highlightRef.current.scrollTop = textareaRef.current.scrollTop;
+ highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx
new file mode 100644
index 0000000..3dbee5b
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx
@@ -0,0 +1,448 @@
+import { GitCommitIcon } from "@diffkit/icons";
+import { Markdown } from "@diffkit/ui/components/markdown";
+import { Skeleton } from "@diffkit/ui/components/skeleton";
+import { cn } from "@diffkit/ui/lib/utils";
+import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import {
+ DetailActivityHeader,
+ DetailCommentBox,
+} from "#/components/details/detail-activity";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import { updatePullBranch } from "#/lib/github.functions";
+import type {
+ PullComment,
+ PullCommit,
+ PullDetail,
+ PullStatus,
+} from "#/lib/github.types";
+
+export function PullDetailActivitySection({
+ comments,
+ commits,
+ isFetching,
+ status,
+ pr,
+ owner,
+ repo,
+ pullNumber,
+}: {
+ comments?: PullComment[];
+ commits?: PullCommit[];
+ isFetching: boolean;
+ status: PullStatus | null;
+ pr: PullDetail;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+}) {
+ return (
+
+
+
+ {isFetching && !comments && (
+
+ )}
+
+ {comments && commits && comments.length === 0 && commits.length === 0 && (
+
No activity yet.
+ )}
+
+
+
+ {!pr.isMerged && pr.state !== "closed" && (
+
+ {status ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+ );
+}
+
+function MergeStatusCard({
+ status,
+ owner,
+ repo,
+ pullNumber,
+}: {
+ status: PullStatus;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+}) {
+ const {
+ checks,
+ reviews,
+ mergeable,
+ mergeableState,
+ behindBy,
+ baseRefName,
+ canUpdateBranch,
+ } = status;
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ const approvedReviews = reviews.filter(
+ (review) => review.state === "APPROVED",
+ );
+ const changesRequested = reviews.filter(
+ (review) => review.state === "CHANGES_REQUESTED",
+ );
+ const pendingReviewers = reviews.filter(
+ (review) => review.state === "PENDING",
+ );
+
+ const hasReviewIssue =
+ changesRequested.length > 0 || pendingReviewers.length > 0;
+ const allChecksPassed =
+ checks.total > 0 && checks.failed === 0 && checks.pending === 0;
+ const hasCheckFailures = checks.failed > 0;
+ const hasChecksPending = checks.pending > 0;
+ const isBehind = behindBy !== null && behindBy > 0;
+ const isMergeBlocked = mergeableState === "blocked" || mergeable === false;
+
+ return (
+
+ 0 ? (
+
+ ) : approvedReviews.length > 0 && !hasReviewIssue ? (
+
+ ) : (
+
+ )
+ }
+ title={
+ changesRequested.length > 0
+ ? "Changes requested"
+ : approvedReviews.length > 0
+ ? `${approvedReviews.length} approving review${approvedReviews.length > 1 ? "s" : ""}`
+ : "Review required"
+ }
+ description={
+ changesRequested.length > 0
+ ? `${changesRequested.map((review) => review.author?.login).join(", ")} requested changes`
+ : approvedReviews.length > 0 && !hasReviewIssue
+ ? "All required reviews have been provided"
+ : "Code owner review required by reviewers with write access."
+ }
+ />
+
+ {checks.total > 0 && (
+
+ ) : hasCheckFailures ? (
+
+ ) : (
+
+ )
+ }
+ title={
+ allChecksPassed
+ ? "All checks have passed"
+ : hasCheckFailures
+ ? `${checks.failed} failing check${checks.failed > 1 ? "s" : ""}`
+ : `${checks.pending} pending check${checks.pending > 1 ? "s" : ""}`
+ }
+ description={
+ `${checks.skipped > 0 ? `${checks.skipped} skipped, ` : ""}${checks.passed} successful check${checks.passed !== 1 ? "s" : ""}` +
+ (hasChecksPending ? `, ${checks.pending} pending` : "") +
+ (hasCheckFailures ? `, ${checks.failed} failing` : "")
+ }
+ />
+ )}
+
+ {isBehind && (
+ }
+ title="This branch is out-of-date with the base branch"
+ description={`Merge the latest changes from ${baseRefName} into this branch.`}
+ action={
+ canUpdateBranch ? (
+
+ ) : undefined
+ }
+ />
+ )}
+
+
+ ) : (
+
+ )
+ }
+ title={isMergeBlocked ? "Merging is blocked" : "Ready to merge"}
+ description={
+ isMergeBlocked
+ ? "All required conditions have not been met."
+ : "All required conditions have been satisfied."
+ }
+ isLast
+ />
+
+ );
+}
+
+function StatusRow({
+ icon,
+ title,
+ description,
+ action,
+ isLast,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ action?: React.ReactNode;
+ isLast?: boolean;
+}) {
+ return (
+
+
{icon}
+
+
{title}
+
{description}
+
+ {action &&
{action}
}
+
+ );
+}
+
+function StatusDot({ color }: { color: string }) {
+ return (
+
+ );
+}
+
+function MergeStatusSkeleton() {
+ return (
+
+ {[0, 1, 2].map((item) => (
+
+ ))}
+
+ );
+}
+
+function UpdateBranchButton({
+ owner,
+ repo,
+ pullNumber,
+ isUpdating,
+ setIsUpdating,
+}: {
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ isUpdating: boolean;
+ setIsUpdating: (value: boolean) => void;
+}) {
+ const queryClient = useQueryClient();
+
+ const handleUpdate = async () => {
+ setIsUpdating(true);
+ try {
+ const success = await updatePullBranch({
+ data: { owner, repo, pullNumber },
+ });
+ if (success) {
+ await queryClient.invalidateQueries({
+ queryKey: ["github"],
+ });
+ }
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+type TimelineItem =
+ | { type: "comment"; date: string; data: PullComment }
+ | { type: "commit"; date: string; data: PullCommit };
+
+function ActivityTimeline({
+ comments,
+ commits,
+}: {
+ comments: PullComment[];
+ commits: PullCommit[];
+}) {
+ const items: TimelineItem[] = [
+ ...comments.map((comment) => ({
+ type: "comment" as const,
+ date: comment.createdAt,
+ data: comment,
+ })),
+ ...commits.map((commit) => ({
+ type: "commit" as const,
+ date: commit.createdAt,
+ data: commit,
+ })),
+ ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+
+ if (items.length === 0) return null;
+
+ return (
+
+ {items.map((item, index) => {
+ const previousType = index > 0 ? items[index - 1].type : null;
+ const nextType =
+ index < items.length - 1 ? items[index + 1].type : null;
+ const isConsecutiveCommit =
+ item.type === "commit" && previousType === "commit";
+ const isLastInCommitRun =
+ item.type === "commit" && nextType !== "commit";
+
+ if (item.type === "comment") {
+ const comment = item.data;
+ return (
+
+
+ {comment.author ? (
+

+ ) : (
+
+ )}
+
+ {comment.author?.login ?? "Unknown"}
+
+
+ {formatRelativeTime(comment.createdAt)}
+
+
+
+ {comment.body}
+
+
+ );
+ }
+
+ const commit = item.data;
+ const firstLine = commit.message.split("\n")[0];
+ return (
+
+
+
+
+ {commit.author ? (
+

+ ) : (
+
+ )}
+
{firstLine}
+
+ {commit.sha.slice(0, 7)}
+
+
+ {formatRelativeTime(commit.createdAt)}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
new file mode 100644
index 0000000..129e9e1
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
@@ -0,0 +1,247 @@
+import {
+ FileIcon,
+ GitCommitIcon,
+ GitMergeIcon,
+ GitPullRequestClosedIcon,
+ GitPullRequestDraftIcon,
+ GitPullRequestIcon,
+ ReviewsIcon,
+} from "@diffkit/icons";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@diffkit/ui/components/tooltip";
+import { cn } from "@diffkit/ui/lib/utils";
+import { Link } from "@tanstack/react-router";
+import { useCallback, useRef, useState } from "react";
+import { DetailPageTitle } from "#/components/details/detail-page";
+import type { PullDetail } from "#/lib/github.types";
+
+type PullStateConfig = {
+ icon: React.ComponentType<{
+ size?: number;
+ strokeWidth?: number;
+ className?: string;
+ }>;
+ color: string;
+ label: string;
+ badgeClass: string;
+};
+
+export function getPrStateConfig(pr: PullDetail): PullStateConfig {
+ if (pr.isDraft) {
+ return {
+ icon: GitPullRequestDraftIcon,
+ color: "text-muted-foreground",
+ label: "Draft",
+ badgeClass: "bg-muted text-muted-foreground",
+ };
+ }
+ if (pr.isMerged || pr.mergedAt) {
+ return {
+ icon: GitMergeIcon,
+ color: "text-purple-500",
+ label: "Merged",
+ badgeClass: "bg-purple-500/10 text-purple-500",
+ };
+ }
+ if (pr.state === "closed") {
+ return {
+ icon: GitPullRequestClosedIcon,
+ color: "text-red-500",
+ label: "Closed",
+ badgeClass: "bg-red-500/10 text-red-500",
+ };
+ }
+ return {
+ icon: GitPullRequestIcon,
+ color: "text-green-500",
+ label: "Open",
+ badgeClass: "bg-green-500/10 text-green-500",
+ };
+}
+
+export function PullDetailHeader({
+ owner,
+ repo,
+ pullId,
+ pr,
+ viewerLogin,
+}: {
+ owner: string;
+ repo: string;
+ pullId: string;
+ pr: PullDetail;
+ viewerLogin?: string | null;
+}) {
+ const stateConfig = getPrStateConfig(pr);
+ const StateIcon = stateConfig.icon;
+ const isReviewRequested =
+ viewerLogin != null &&
+ pr.requestedReviewers.some((reviewer) => reviewer.login === viewerLogin);
+
+ return (
+ <>
+
+
+ {stateConfig.label}
+
+ {pr.author && (
+ <>
+
+
+ {pr.author.login}
+
+ wants to merge into
+
+ from
+
+ >
+ )}
+
+ }
+ />
+
+
+ {isReviewRequested && (
+
+
+
+ Your review has been requested
+
+
+ Review changes
+
+
+ )}
+
+
+
+
+
+ {pr.commits}
+ {" "}
+ {pr.commits === 1 ? "commit" : "commits"}
+
+ ·
+
+
+
+ {pr.changedFiles}
+ {" "}
+ {pr.changedFiles === 1 ? "file" : "files"} changed
+
+
+
+
+ +{pr.additions}
+
+
+ -{pr.deletions}
+
+
+
+ {!pr.isMerged && !isReviewRequested && (
+
+ Review changes
+
+ )}
+
+
+
+ >
+ );
+}
+
+const DIFF_BOX_COUNT = 5;
+
+function CopyBadge({
+ value,
+ canTruncate,
+}: {
+ value: string;
+ canTruncate?: boolean;
+}) {
+ const [copied, setCopied] = useState(false);
+ const timeoutRef = useRef>(undefined);
+
+ const handleClick = useCallback(() => {
+ void navigator.clipboard.writeText(value);
+ setCopied(true);
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setCopied(false), 1500);
+ }, [value]);
+
+ return (
+
+
+
+
+ Copied!
+
+ );
+}
+
+function DiffBoxes({
+ additions,
+ deletions,
+}: {
+ additions: number;
+ deletions: number;
+}) {
+ const total = additions + deletions;
+ const greenCount =
+ total === 0 ? 0 : Math.round((additions / total) * DIFF_BOX_COUNT);
+ const redCount = total === 0 ? 0 : DIFF_BOX_COUNT - greenCount;
+
+ const boxes: string[] = [];
+ for (let i = 0; i < greenCount; i++) boxes.push("bg-green-500");
+ for (let i = 0; i < redCount; i++) boxes.push("bg-red-500");
+ while (boxes.length < DIFF_BOX_COUNT) boxes.push("bg-muted-foreground/30");
+
+ return (
+
+ {boxes.map((color, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: static decorative boxes, order never changes
+
+ ))}
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx
new file mode 100644
index 0000000..fb8f15e
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx
@@ -0,0 +1,188 @@
+import { Skeleton } from "@diffkit/ui/components/skeleton";
+import { useQuery } from "@tanstack/react-query";
+import { getRouteApi } from "@tanstack/react-router";
+import {
+ DetailPageLayout,
+ DetailPageSkeletonLayout,
+} from "#/components/details/detail-page";
+import {
+ githubPullPageQueryOptions,
+ githubPullStatusQueryOptions,
+ githubViewerQueryOptions,
+} from "#/lib/github.query";
+import { githubCachePolicy } from "#/lib/github-cache-policy";
+import { useHasMounted } from "#/lib/use-has-mounted";
+import { useRegisterTab } from "#/lib/use-register-tab";
+import { PullBodySection } from "./pull-body-section";
+import { PullDetailActivitySection } from "./pull-detail-activity";
+import { getPrStateConfig, PullDetailHeader } from "./pull-detail-header";
+import { PullDetailSidebar } from "./pull-detail-sidebar";
+
+const routeApi = getRouteApi("/_protected/$owner/$repo/pull/$pullId");
+
+export function PullDetailPage() {
+ const { user } = routeApi.useRouteContext();
+ const { owner, repo, pullId } = routeApi.useParams();
+ const pullNumber = Number(pullId);
+ const scope = { userId: user.id };
+ const hasMounted = useHasMounted();
+
+ const pageQuery = useQuery({
+ ...githubPullPageQueryOptions(scope, { owner, repo, pullNumber }),
+ enabled: hasMounted,
+ });
+
+ const statusQuery = useQuery({
+ ...githubPullStatusQueryOptions(scope, { owner, repo, pullNumber }),
+ enabled: hasMounted && pageQuery.data?.detail != null,
+ refetchOnWindowFocus: "always",
+ refetchInterval: githubCachePolicy.status.staleTimeMs,
+ });
+
+ const viewerQuery = useQuery({
+ ...githubViewerQueryOptions(scope),
+ enabled: hasMounted,
+ });
+
+ const pr = pageQuery.data?.detail;
+ const comments = pageQuery.data?.comments;
+ const commits = pageQuery.data?.commits;
+ const status = statusQuery.data ?? null;
+ const viewer = viewerQuery.data ?? null;
+
+ useRegisterTab(
+ pr
+ ? {
+ type: "pull",
+ title: pr.title,
+ number: pr.number,
+ url: `/${owner}/${repo}/pull/${pullId}`,
+ repo: `${owner}/${repo}`,
+ iconColor: getPrStateConfig(pr).color,
+ }
+ : null,
+ );
+
+ if (pageQuery.error) throw pageQuery.error;
+ if (!pr) return ;
+
+ return (
+
+
+
+
+
+
+ >
+ }
+ sidebar={
+
+ }
+ />
+ );
+}
+
+function PullDetailPageSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map((item) => (
+
+ ))}
+
+
+ {[0, 1, 2].map((item) => (
+
+ ))}
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-sidebar.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-sidebar.tsx
new file mode 100644
index 0000000..a1d6f39
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-sidebar.tsx
@@ -0,0 +1,545 @@
+import {
+ CalendarIcon,
+ CheckIcon,
+ ClockIcon,
+ CloseIcon,
+ CommentIcon,
+ GitMergeIcon,
+ MessageIcon,
+ PlusSignIcon,
+ SearchIcon,
+} from "@diffkit/icons";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@diffkit/ui/components/popover";
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useMemo, useRef, useState } from "react";
+import {
+ DetailParticipantAvatars,
+ DetailSidebar,
+ DetailSidebarRow,
+ DetailSidebarSection,
+} from "#/components/details/detail-sidebar";
+import { LabelsSection } from "#/components/issues/labels-section";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import {
+ removeReviewRequest,
+ requestPullReviewers,
+} from "#/lib/github.functions";
+import {
+ type GitHubQueryScope,
+ githubOrgTeamsQueryOptions,
+ githubQueryKeys,
+ githubRepoCollaboratorsQueryOptions,
+} from "#/lib/github.query";
+import type {
+ GitHubActor,
+ PullComment,
+ PullCommit,
+ PullDetail,
+ PullPageData,
+} from "#/lib/github.types";
+import { useOptimisticMutation } from "#/lib/use-optimistic-mutation";
+
+export function PullDetailSidebar({
+ pr,
+ owner,
+ repo,
+ pullNumber,
+ scope,
+ comments,
+ commits,
+}: {
+ pr: PullDetail;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ scope: GitHubQueryScope;
+ comments: PullComment[];
+ commits: PullCommit[];
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatRelativeTime(pr.createdAt)}
+
+
+ {formatRelativeTime(pr.updatedAt)}
+
+ {pr.mergedAt && (
+
+ {formatRelativeTime(pr.mergedAt)}
+
+ )}
+ {pr.closedAt && !pr.mergedAt && (
+
+ {formatRelativeTime(pr.closedAt)}
+
+ )}
+
+ {pr.comments}
+
+
+ {pr.reviewComments}
+
+
+
+
+ );
+}
+
+function ReviewersSection({
+ pr,
+ owner,
+ repo,
+ pullNumber,
+ scope,
+}: {
+ pr: PullDetail;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ scope: GitHubQueryScope;
+}) {
+ const { mutate } = useOptimisticMutation();
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+ const listRef = useRef(null);
+
+ const collaboratorsQuery = useQuery({
+ ...githubRepoCollaboratorsQueryOptions(scope, { owner, repo }),
+ enabled: pickerOpen,
+ });
+ const teamsQuery = useQuery({
+ ...githubOrgTeamsQueryOptions(scope, owner),
+ enabled: pickerOpen,
+ });
+
+ const collaborators = collaboratorsQuery.data ?? [];
+ const teams = teamsQuery.data ?? [];
+ const isLoading = collaboratorsQuery.isLoading || teamsQuery.isLoading;
+ const isOpen = !pr.isMerged && pr.state !== "closed";
+
+ const requestedLogins = useMemo(
+ () => new Set(pr.requestedReviewers.map((reviewer) => reviewer.login)),
+ [pr.requestedReviewers],
+ );
+ const requestedTeamSlugs = useMemo(
+ () => new Set(pr.requestedTeams.map((team) => team.slug)),
+ [pr.requestedTeams],
+ );
+
+ const candidates = useMemo(() => {
+ const authorLogin = pr.author?.login;
+ return collaborators.filter(
+ (collaborator) => collaborator.login !== authorLogin,
+ );
+ }, [collaborators, pr.author?.login]);
+
+ const filteredUsers = useMemo(() => {
+ if (!search) return candidates;
+ const query = search.toLowerCase();
+ return candidates.filter((candidate) =>
+ candidate.login.toLowerCase().includes(query),
+ );
+ }, [candidates, search]);
+
+ const filteredTeams = useMemo(() => {
+ if (!search) return teams;
+ const query = search.toLowerCase();
+ return teams.filter(
+ (team) =>
+ team.name.toLowerCase().includes(query) ||
+ team.slug.toLowerCase().includes(query),
+ );
+ }, [teams, search]);
+
+ const pageQueryKey = githubQueryKeys.pulls.page(scope, {
+ owner,
+ repo,
+ pullNumber,
+ });
+
+ const toggleReviewer = (login: string) => {
+ const isRequested = requestedLogins.has(login);
+ const collaborator = collaborators.find(
+ (candidate) => candidate.login === login,
+ );
+
+ mutate({
+ mutationFn: () =>
+ isRequested
+ ? removeReviewRequest({
+ data: { owner, repo, pullNumber, reviewers: [login] },
+ })
+ : requestPullReviewers({
+ data: { owner, repo, pullNumber, reviewers: [login] },
+ }),
+ updates: [
+ {
+ queryKey: pageQueryKey,
+ updater: (prev: PullPageData) => ({
+ ...prev,
+ detail: prev.detail
+ ? {
+ ...prev.detail,
+ requestedReviewers: isRequested
+ ? prev.detail.requestedReviewers.filter(
+ (reviewer) => reviewer.login !== login,
+ )
+ : [
+ ...prev.detail.requestedReviewers,
+ {
+ login,
+ avatarUrl: collaborator?.avatarUrl ?? "",
+ url: `https://github.com/${login}`,
+ type: "User",
+ },
+ ],
+ }
+ : prev.detail,
+ }),
+ },
+ ],
+ });
+ };
+
+ const toggleTeam = (slug: string) => {
+ const isRequested = requestedTeamSlugs.has(slug);
+ const team = teams.find((candidate) => candidate.slug === slug);
+
+ mutate({
+ mutationFn: () =>
+ isRequested
+ ? removeReviewRequest({
+ data: { owner, repo, pullNumber, teamReviewers: [slug] },
+ })
+ : requestPullReviewers({
+ data: { owner, repo, pullNumber, teamReviewers: [slug] },
+ }),
+ updates: [
+ {
+ queryKey: pageQueryKey,
+ updater: (prev: PullPageData) => ({
+ ...prev,
+ detail: prev.detail
+ ? {
+ ...prev.detail,
+ requestedTeams: isRequested
+ ? prev.detail.requestedTeams.filter(
+ (requestedTeam) => requestedTeam.slug !== slug,
+ )
+ : [
+ ...prev.detail.requestedTeams,
+ {
+ slug,
+ name: team?.name ?? slug,
+ url: `https://github.com/orgs/${owner}/teams/${slug}`,
+ },
+ ],
+ }
+ : prev.detail,
+ }),
+ },
+ ],
+ });
+ };
+
+ const hasReviewers =
+ pr.requestedReviewers.length > 0 || pr.requestedTeams.length > 0;
+
+ type ReviewerItem =
+ | { kind: "team"; slug: string }
+ | { kind: "user"; login: string };
+
+ const flatItems = useMemo(() => {
+ const items: ReviewerItem[] = [];
+ for (const team of filteredTeams)
+ items.push({ kind: "team", slug: team.slug });
+ for (const collaborator of filteredUsers) {
+ items.push({ kind: "user", login: collaborator.login });
+ }
+ return items;
+ }, [filteredTeams, filteredUsers]);
+
+ const scrollToFocused = useCallback((index: number) => {
+ const element = listRef.current?.querySelector(`[data-index="${index}"]`);
+ if (element) {
+ element.scrollIntoView({ block: "nearest" });
+ }
+ }, []);
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (flatItems.length === 0) return;
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ const next = focusedIndex < flatItems.length - 1 ? focusedIndex + 1 : 0;
+ setFocusedIndex(next);
+ scrollToFocused(next);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ const next = focusedIndex > 0 ? focusedIndex - 1 : flatItems.length - 1;
+ setFocusedIndex(next);
+ scrollToFocused(next);
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ if (focusedIndex < 0) return;
+ const item = flatItems[focusedIndex];
+ if (item.kind === "team") {
+ toggleTeam(item.slug);
+ } else {
+ toggleReviewer(item.login);
+ }
+ }
+ };
+
+ return (
+
+
+
+ Reviewers
+
+ {isOpen && (
+
{
+ setPickerOpen(open);
+ if (!open) {
+ setSearch("");
+ setFocusedIndex(-1);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {
+ setSearch(event.target.value);
+ setFocusedIndex(-1);
+ }}
+ onKeyDown={handleKeyDown}
+ placeholder="Search people and teams..."
+ className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+ />
+
+
+ {isLoading ? (
+
+ Loading…
+
+ ) : filteredUsers.length === 0 && filteredTeams.length === 0 ? (
+
+ No results found
+
+ ) : (
+ <>
+ {filteredTeams.length > 0 && (
+ <>
+
+ Teams
+
+ {filteredTeams.map((team, index) => {
+ const isSelected = requestedTeamSlugs.has(team.slug);
+ return (
+
+ );
+ })}
+ >
+ )}
+ {filteredUsers.length > 0 && (
+ <>
+
+ People
+
+ {filteredUsers.map((collaborator, index) => {
+ const itemIndex = filteredTeams.length + index;
+ const isSelected = requestedLogins.has(
+ collaborator.login,
+ );
+ return (
+
+ );
+ })}
+ >
+ )}
+ >
+ )}
+
+
+
+ )}
+
+ {hasReviewers ? (
+
+ {pr.requestedTeams.map((team) => (
+
+
+ T
+
+
+ {team.name}
+
+ {isOpen && (
+
+ )}
+
+ ))}
+ {pr.requestedReviewers.map((reviewer) => (
+
+

+
+ {reviewer.login}
+
+ {isOpen && (
+
+ )}
+
+ ))}
+
+ ) : (
+
No reviewers requested
+ )}
+
+ );
+}
+
+function ParticipantsList({
+ pr,
+ comments,
+ commits,
+}: {
+ pr: PullDetail;
+ comments: PullComment[];
+ commits: PullCommit[];
+}) {
+ const seen = new Set();
+ const participants: GitHubActor[] = [];
+
+ const addActor = (actor: GitHubActor | null) => {
+ if (actor && !seen.has(actor.login)) {
+ seen.add(actor.login);
+ participants.push(actor);
+ }
+ };
+
+ addActor(pr.author);
+ for (const comment of comments) {
+ addActor(comment.author);
+ }
+ for (const commit of commits) {
+ addActor(commit.author);
+ }
+
+ if (participants.length === 0) {
+ return No participants yet
;
+ }
+
+ return ;
+}
diff --git a/apps/dashboard/src/components/pulls/pull-request-row.tsx b/apps/dashboard/src/components/pulls/pull-request-row.tsx
index 0e09ad6..7ade4fe 100644
--- a/apps/dashboard/src/components/pulls/pull-request-row.tsx
+++ b/apps/dashboard/src/components/pulls/pull-request-row.tsx
@@ -12,6 +12,7 @@ import { cn } from "@diffkit/ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { Link, useRouter } from "@tanstack/react-router";
import { useState } from "react";
+import { formatRelativeTime } from "#/lib/format-relative-time";
import {
type GitHubQueryScope,
githubPullCommentsQueryOptions,
@@ -19,21 +20,6 @@ import {
import type { PullSummary } from "#/lib/github.types";
import { preloadRouteOnce } from "#/lib/route-preload";
-export function formatRelativeTime(dateStr: string): string {
- const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
- if (seconds < 60) return "just now";
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- if (days < 30) return `${days}d ago`;
- const months = Math.floor(days / 30);
- if (months < 12) return `${months}mo ago`;
- const years = Math.floor(months / 12);
- return `${years}y ago`;
-}
-
function getPrStateProps(pr: PullSummary) {
if (pr.isDraft) {
return { icon: GitPullRequestDraftIcon, color: "text-muted-foreground" };
diff --git a/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx b/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx
new file mode 100644
index 0000000..aba19ed
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx
@@ -0,0 +1,378 @@
+import { CommentIcon } from "@diffkit/icons";
+import { Markdown } from "@diffkit/ui/components/markdown";
+import { vercelDark, vercelLight } from "@diffkit/ui/lib/shiki-themes";
+import { cn } from "@diffkit/ui/lib/utils";
+import type { SelectedLineRange } from "@pierre/diffs";
+import type { DiffLineAnnotation, PatchDiffProps } from "@pierre/diffs/react";
+import { useTheme } from "next-themes";
+import {
+ type ComponentType,
+ type LazyExoticComponent,
+ lazy,
+ Suspense,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import type { PullFile, PullReviewComment } from "#/lib/github.types";
+import type {
+ ActiveCommentForm,
+ PendingComment,
+ ReviewAnnotation,
+} from "./review-types";
+import { buildPatchString, encodeFileId } from "./review-utils";
+
+type ReviewPatchDiffComponent = ComponentType>;
+
+const PatchDiff: LazyExoticComponent = lazy(() =>
+ import.meta.env.SSR
+ ? Promise.resolve({
+ default: (() => null) as ReviewPatchDiffComponent,
+ })
+ : import("@pierre/diffs/react").then((mod) => ({
+ default: mod.PatchDiff as ReviewPatchDiffComponent,
+ })),
+);
+
+if (!import.meta.env.SSR) {
+ import("@pierre/diffs").then(({ registerCustomTheme }) => {
+ registerCustomTheme("vercel-light", () => Promise.resolve(vercelLight));
+ registerCustomTheme("vercel-dark", () => Promise.resolve(vercelDark));
+ });
+}
+
+export function ReviewFileDiffBlock({
+ file,
+ diffStyle,
+ annotations,
+ pendingComments,
+ activeCommentForm,
+ selectedLines,
+ onGutterClick,
+ onCancelComment,
+ onAddComment,
+}: {
+ file: PullFile;
+ diffStyle: "unified" | "split";
+ annotations: DiffLineAnnotation[];
+ pendingComments: PendingComment[];
+ activeCommentForm: ActiveCommentForm | null;
+ selectedLines: SelectedLineRange | null;
+ onGutterClick: (range: SelectedLineRange) => void;
+ onCancelComment: () => void;
+ onAddComment: (comment: PendingComment) => void;
+}) {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+ const { resolvedTheme } = useTheme();
+ const isDark = resolvedTheme === "dark";
+
+ const allAnnotations = useMemo(() => {
+ const result: DiffLineAnnotation[] = [...annotations];
+
+ for (const pending of pendingComments) {
+ result.push({
+ side: pending.side === "LEFT" ? "deletions" : "additions",
+ lineNumber: pending.line,
+ metadata: pending,
+ });
+ }
+
+ if (activeCommentForm) {
+ result.push({
+ side: activeCommentForm.side === "LEFT" ? "deletions" : "additions",
+ lineNumber: activeCommentForm.line,
+ metadata: {
+ path: activeCommentForm.path,
+ line: activeCommentForm.line,
+ startLine: activeCommentForm.startLine,
+ side: activeCommentForm.side,
+ startSide: activeCommentForm.startSide,
+ body: "__FORM__",
+ } satisfies PendingComment,
+ });
+ }
+
+ return result;
+ }, [annotations, pendingComments, activeCommentForm]);
+
+ const mutedFg = isDark
+ ? "oklch(0.705 0.015 286.067)"
+ : "oklch(0.552 0.016 285.938)";
+
+ const diffOptions = useMemo(
+ () => ({
+ diffStyle,
+ theme: {
+ dark: "vercel-dark" as const,
+ light: "vercel-light" as const,
+ },
+ lineDiffType: "word" as const,
+ hunkSeparators: "line-info" as const,
+ overflow: "scroll" as const,
+ disableFileHeader: true,
+ enableGutterUtility: true,
+ enableLineSelection: true,
+ onGutterUtilityClick: onGutterClick,
+ unsafeCSS: [
+ `:host { color-scheme: ${isDark ? "dark" : "light"}; ${isDark ? "" : "--diffs-light-bg: oklch(0.967 0.001 286.375);"} }`,
+ `:host { --diffs-font-family: 'Geist Mono Variable', 'SF Mono', ui-monospace, 'Cascadia Code', monospace; }`,
+ `:host { --diffs-selection-base: ${mutedFg}; }`,
+ `[data-utility-button] { background-color: ${mutedFg}; }`,
+ `[data-line-annotation] { font-family: 'Inter Variable', 'Inter', 'Avenir Next', ui-sans-serif, system-ui, sans-serif; }`,
+ `[data-line-annotation] code { font-family: var(--diffs-font-family, var(--diffs-font-fallback)); }`,
+ isDark
+ ? `:host { --diffs-bg-addition-override: color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base)); --diffs-bg-addition-number-override: color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-addition-base)); --diffs-bg-addition-emphasis-override: color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-addition-base)); --diffs-bg-deletion-override: color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)); --diffs-bg-deletion-number-override: color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-deletion-base)); --diffs-bg-deletion-emphasis-override: color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)); }`
+ : `:host { --diffs-bg-addition-override: color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-addition-base)); --diffs-bg-addition-number-override: color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-addition-base)); --diffs-bg-deletion-override: color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-deletion-base)); --diffs-bg-deletion-number-override: color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-deletion-base)); }`,
+ ].join("\n"),
+ }),
+ [diffStyle, isDark, mutedFg, onGutterClick],
+ );
+
+ if (!file.patch) {
+ return (
+
+
setIsCollapsed(!isCollapsed)}
+ />
+ {!isCollapsed && (
+
+ Binary file or diff too large to display
+
+ )}
+
+ );
+ }
+
+ const patchString = buildPatchString(file);
+
+ return (
+
+
setIsCollapsed(!isCollapsed)}
+ />
+ {!isCollapsed && (
+
+
+ ,
+ ) => {
+ const data = annotation.metadata as
+ | PendingComment
+ | PullReviewComment
+ | null;
+ if (!data) return null;
+
+ if ("body" in data && data.body === "__FORM__") {
+ const formData = data as PendingComment;
+ return (
+
+ onAddComment({
+ path: file.filename,
+ line: formData.line,
+ startLine: formData.startLine,
+ side: formData.side,
+ startSide: formData.startSide,
+ body,
+ })
+ }
+ onCancel={onCancelComment}
+ />
+ );
+ }
+
+ if ("body" in data && !("id" in data)) {
+ return (
+
+ );
+ }
+
+ if ("id" in data) {
+ return (
+
+ );
+ }
+
+ return null;
+ }}
+ />
+
+
+ )}
+
+ );
+}
+
+function FileHeader({
+ file,
+ isCollapsed,
+ onToggleCollapse,
+}: {
+ file: PullFile;
+ isCollapsed: boolean;
+ onToggleCollapse: () => void;
+}) {
+ return (
+
+
+
+
+ {file.previousFilename && file.previousFilename !== file.filename ? (
+ <>
+
+ {file.previousFilename}
+
+ →
+ {file.filename}
+ >
+ ) : (
+ file.filename
+ )}
+
+
+
+ {file.additions > 0 && (
+ +{file.additions}
+ )}
+ {file.deletions > 0 && (
+ -{file.deletions}
+ )}
+
+
+ );
+}
+
+function InlineCommentForm({
+ isMultiLine,
+ startLine,
+ endLine,
+ onSubmit,
+ onCancel,
+}: {
+ isMultiLine?: boolean;
+ startLine?: number;
+ endLine?: number;
+ onSubmit: (body: string) => void;
+ onCancel: () => void;
+}) {
+ const [body, setBody] = useState("");
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ textareaRef.current?.focus();
+ }, []);
+
+ return (
+
+ {isMultiLine && startLine != null && endLine != null && (
+
+ Commenting on lines {startLine}–{endLine}
+
+ )}
+
+ );
+}
+
+function ReviewCommentBubble({ comment }: { comment: PullReviewComment }) {
+ return (
+
+
+ {comment.author && (
+ <>
+

+
{comment.author.login}
+ >
+ )}
+
+ {formatRelativeTime(comment.createdAt)}
+
+
+
+ {comment.body}
+
+
+ );
+}
+
+function PendingCommentBubble({ comment }: { comment: PendingComment }) {
+ const isMultiLine =
+ comment.startLine != null && comment.startLine !== comment.line;
+
+ return (
+
+
+
+
+ Pending
+ {isMultiLine ? ` (lines ${comment.startLine}–${comment.line})` : ""}
+
+
+
+ {comment.body}
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/review/review-file-tree.tsx b/apps/dashboard/src/components/pulls/review/review-file-tree.tsx
new file mode 100644
index 0000000..3e01e54
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/review/review-file-tree.tsx
@@ -0,0 +1,97 @@
+import { FileIcon, FolderIcon } from "@diffkit/icons";
+import { cn } from "@diffkit/ui/lib/utils";
+import { useState } from "react";
+import type { FileTreeNode } from "./review-types";
+
+export function ReviewFileTreeNode({
+ node,
+ depth,
+ activeFile,
+ onFileClick,
+}: {
+ node: FileTreeNode;
+ depth: number;
+ activeFile: string | null;
+ onFileClick: (path: string) => void;
+}) {
+ const [isOpen, setIsOpen] = useState(true);
+
+ if (node.type === "directory") {
+ return (
+
+
+ {isOpen && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ const isActive = activeFile === node.path;
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/review/review-page.tsx b/apps/dashboard/src/components/pulls/review/review-page.tsx
new file mode 100644
index 0000000..4837621
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/review/review-page.tsx
@@ -0,0 +1,420 @@
+import { FileIcon, GitPullRequestIcon, SearchIcon } from "@diffkit/icons";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "@diffkit/ui/components/resizable";
+import { cn } from "@diffkit/ui/lib/utils";
+import type { SelectedLineRange } from "@pierre/diffs";
+import type { DiffLineAnnotation } from "@pierre/diffs/react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { getRouteApi, Link } from "@tanstack/react-router";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { getPrStateConfig } from "#/components/pulls/detail/pull-detail-header";
+import { submitPullReview } from "#/lib/github.functions";
+import {
+ githubPullFilesQueryOptions,
+ githubPullPageQueryOptions,
+ githubPullReviewCommentsQueryOptions,
+ githubQueryKeys,
+} from "#/lib/github.query";
+import type { PullReviewComment } from "#/lib/github.types";
+import { useHasMounted } from "#/lib/use-has-mounted";
+import { useRegisterTab } from "#/lib/use-register-tab";
+import { ReviewFileDiffBlock } from "./review-file-diff-block";
+import { ReviewFileTreeNode } from "./review-file-tree";
+import { ReviewSubmitPopover } from "./review-submit-popover";
+import type {
+ ActiveCommentForm,
+ FileTreeNode,
+ PendingComment,
+ ReviewEvent,
+} from "./review-types";
+import { buildFileTree, encodeFileId } from "./review-utils";
+
+const routeApi = getRouteApi("/_protected/$owner/$repo/review/$pullId");
+
+export function ReviewPage() {
+ const { user } = routeApi.useRouteContext();
+ const { owner, repo, pullId } = routeApi.useParams();
+ const pullNumber = Number(pullId);
+ const scope = { userId: user.id };
+ const hasMounted = useHasMounted();
+ const queryClient = useQueryClient();
+ const input = { owner, repo, pullNumber };
+
+ const pageQuery = useQuery({
+ ...githubPullPageQueryOptions(scope, input),
+ enabled: hasMounted,
+ });
+
+ const filesQuery = useQuery({
+ ...githubPullFilesQueryOptions(scope, input),
+ enabled: hasMounted,
+ });
+
+ const reviewCommentsQuery = useQuery({
+ ...githubPullReviewCommentsQueryOptions(scope, input),
+ enabled: hasMounted,
+ });
+
+ const pr = pageQuery.data?.detail;
+ const files = filesQuery.data ?? [];
+ const reviewComments = reviewCommentsQuery.data ?? [];
+
+ const [diffStyle, setDiffStyle] = useState<"unified" | "split">("unified");
+ const [pendingComments, setPendingComments] = useState([]);
+ const [activeCommentForm, setActiveCommentForm] =
+ useState(null);
+ const [selectedLines, setSelectedLines] = useState(
+ null,
+ );
+ const [activeFile, setActiveFile] = useState(null);
+ const [fileFilter, setFileFilter] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const diffPanelRef = useRef(null);
+
+ useRegisterTab(
+ pr
+ ? {
+ type: "review",
+ title: pr.title,
+ number: pr.number,
+ url: `/${owner}/${repo}/review/${pullId}`,
+ repo: `${owner}/${repo}`,
+ iconColor: getPrStateConfig(pr).color,
+ additions: pr.additions,
+ deletions: pr.deletions,
+ }
+ : null,
+ );
+
+ const fileTree = useMemo(() => buildFileTree(files), [files]);
+
+ const filteredTree = useMemo(() => {
+ if (!fileFilter) return fileTree;
+ const lower = fileFilter.toLowerCase();
+
+ function filterNodes(nodes: FileTreeNode[]): FileTreeNode[] {
+ return nodes
+ .map((node) => {
+ if (node.type === "file") {
+ return node.name.toLowerCase().includes(lower) ? node : null;
+ }
+
+ const filteredChildren = filterNodes(node.children);
+ return filteredChildren.length > 0
+ ? { ...node, children: filteredChildren }
+ : null;
+ })
+ .filter(Boolean) as FileTreeNode[];
+ }
+
+ return filterNodes(fileTree);
+ }, [fileFilter, fileTree]);
+
+ const scrollToFile = useCallback((filename: string) => {
+ const element = document.getElementById(encodeFileId(filename));
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
+ setActiveFile(filename);
+ }
+ }, []);
+
+ useEffect(() => {
+ const panel = diffPanelRef.current;
+ if (!panel || files.length === 0) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ const filename = entry.target.getAttribute("data-filename");
+ if (filename) setActiveFile(filename);
+ }
+ }
+ },
+ {
+ root: panel,
+ rootMargin: "-10% 0px -80% 0px",
+ threshold: 0,
+ },
+ );
+
+ for (const file of files) {
+ const element = document.getElementById(encodeFileId(file.filename));
+ if (element) observer.observe(element);
+ }
+
+ return () => observer.disconnect();
+ }, [files]);
+
+ const annotationsByFile = useMemo(() => {
+ const map = new Map[]>();
+ for (const comment of reviewComments) {
+ if (comment.line == null) continue;
+ const existing = map.get(comment.path) ?? [];
+ existing.push({
+ side: comment.side === "LEFT" ? "deletions" : "additions",
+ lineNumber: comment.line,
+ metadata: comment,
+ });
+ map.set(comment.path, existing);
+ }
+ return map;
+ }, [reviewComments]);
+
+ const addPendingComment = useCallback((comment: PendingComment) => {
+ setPendingComments((previous) => [...previous, comment]);
+ setActiveCommentForm(null);
+ }, []);
+
+ const handleSubmitReview = useCallback(
+ async (body: string, event: ReviewEvent) => {
+ setIsSubmitting(true);
+ try {
+ const success = await submitPullReview({
+ data: {
+ owner,
+ repo,
+ pullNumber,
+ body,
+ event,
+ comments: pendingComments.map((comment) => ({
+ path: comment.path,
+ line: comment.line,
+ side: comment.side,
+ body: comment.body,
+ ...(comment.startLine != null &&
+ comment.startLine !== comment.line
+ ? {
+ startLine: comment.startLine,
+ startSide: comment.startSide ?? comment.side,
+ }
+ : {}),
+ })),
+ },
+ });
+
+ if (success) {
+ setPendingComments([]);
+ void queryClient.invalidateQueries({
+ queryKey: githubQueryKeys.all,
+ });
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [owner, pendingComments, pullNumber, queryClient, repo],
+ );
+
+ if (pageQuery.error) throw pageQuery.error;
+
+ if (!pr) {
+ return (
+
+ );
+ }
+
+ const stateConfig = getPrStateConfig(pr);
+ const StateIcon = stateConfig.icon;
+ const totalAdditions = files.reduce((sum, file) => sum + file.additions, 0);
+ const totalDeletions = files.reduce((sum, file) => sum + file.deletions, 0);
+
+ return (
+
+
+
+
+
#{pr.number}
+
+
+
+
+
+
+
+
+
+
+
+ {files.length}
+ {" "}
+ {files.length === 1 ? "file" : "files"}
+
+
+ +{totalAdditions}
+
+
+ -{totalDeletions}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setFileFilter(event.target.value)}
+ className="ml-2 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
+ />
+
+
+
+
+ {filteredTree.map((node) => (
+
+ ))}
+
+
+
+ {files.length} {files.length === 1 ? "file" : "files"} changed
+
+
+
+
+
+
+
+
+
+ {files.map((file) => (
+
comment.path === file.filename,
+ )}
+ activeCommentForm={
+ activeCommentForm?.path === file.filename
+ ? activeCommentForm
+ : null
+ }
+ selectedLines={
+ activeCommentForm?.path === file.filename
+ ? selectedLines
+ : null
+ }
+ onGutterClick={(range) => {
+ const isMultiLine = range.start !== range.end;
+ const startIsSmaller = range.start <= range.end;
+ const lineSide = startIsSmaller
+ ? (range.endSide ?? range.side)
+ : range.side;
+ const startLineSide = startIsSmaller
+ ? range.side
+ : (range.endSide ?? range.side);
+ const toGithubSide = (s: string | undefined) =>
+ s === "deletions"
+ ? ("LEFT" as const)
+ : ("RIGHT" as const);
+ setActiveCommentForm({
+ path: file.filename,
+ line: Math.max(range.start, range.end),
+ side: toGithubSide(lineSide),
+ ...(isMultiLine
+ ? {
+ startLine: Math.min(range.start, range.end),
+ startSide: toGithubSide(startLineSide),
+ }
+ : {}),
+ });
+ setSelectedLines(range);
+ }}
+ onCancelComment={() => {
+ setActiveCommentForm(null);
+ setSelectedLines(null);
+ }}
+ onAddComment={(comment) => {
+ addPendingComment(comment);
+ setSelectedLines(null);
+ }}
+ />
+ ))}
+
+ {files.length === 0 && !filesQuery.isLoading && (
+
+ No files changed in this pull request.
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/review/review-submit-popover.tsx b/apps/dashboard/src/components/pulls/review/review-submit-popover.tsx
new file mode 100644
index 0000000..fa1a7f8
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/review/review-submit-popover.tsx
@@ -0,0 +1,161 @@
+import {
+ CloseIcon,
+ CommentIcon,
+ GitBranchIcon,
+ TickIcon,
+} from "@diffkit/icons";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@diffkit/ui/components/popover";
+import { cn } from "@diffkit/ui/lib/utils";
+import { useState } from "react";
+import type { ReviewEvent } from "./review-types";
+
+export function ReviewSubmitPopover({
+ pendingCount,
+ isSubmitting,
+ onSubmit,
+}: {
+ pendingCount: number;
+ isSubmitting: boolean;
+ onSubmit: (body: string, event: ReviewEvent) => void;
+}) {
+ const [body, setBody] = useState("");
+ const [event, setEvent] = useState("COMMENT");
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleSubmit = () => {
+ onSubmit(body, event);
+ setBody("");
+ setIsOpen(false);
+ };
+
+ const reviewOptions: Array<{
+ value: ReviewEvent;
+ label: string;
+ description: string;
+ icon: typeof CommentIcon;
+ color: string;
+ }> = [
+ {
+ value: "COMMENT",
+ label: "Comment",
+ description: "Submit general feedback without explicit approval.",
+ icon: CommentIcon,
+ color: "text-foreground",
+ },
+ {
+ value: "APPROVE",
+ label: "Approve",
+ description: "Submit feedback and approve merging these changes.",
+ icon: TickIcon,
+ color: "text-green-500",
+ },
+ {
+ value: "REQUEST_CHANGES",
+ label: "Request changes",
+ description: "Submit feedback suggesting changes.",
+ icon: GitBranchIcon,
+ color: "text-red-500",
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
Finish your review
+
+
+
+
+
+
+
+ {reviewOptions.map((option) => {
+ const Icon = option.icon;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/pulls/review/review-types.ts b/apps/dashboard/src/components/pulls/review/review-types.ts
new file mode 100644
index 0000000..9c1f56b
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/review/review-types.ts
@@ -0,0 +1,25 @@
+import type { PullFile, PullReviewComment } from "#/lib/github.types";
+
+export type PendingComment = {
+ path: string;
+ line: number;
+ startLine?: number;
+ side: "LEFT" | "RIGHT";
+ startSide?: "LEFT" | "RIGHT";
+ body: string;
+};
+
+export type ActiveCommentForm = Omit;
+
+export type ReviewAnnotation = PullReviewComment | PendingComment;
+export type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
+
+export type FileTreeNode = {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ status?: PullFile["status"];
+ additions?: number;
+ deletions?: number;
+ children: FileTreeNode[];
+};
diff --git a/apps/dashboard/src/components/pulls/review/review-utils.ts b/apps/dashboard/src/components/pulls/review/review-utils.ts
new file mode 100644
index 0000000..f03db50
--- /dev/null
+++ b/apps/dashboard/src/components/pulls/review/review-utils.ts
@@ -0,0 +1,78 @@
+import type { PullFile } from "#/lib/github.types";
+import type { FileTreeNode } from "./review-types";
+
+export function buildFileTree(files: PullFile[]): FileTreeNode[] {
+ const root: FileTreeNode = {
+ name: "",
+ path: "",
+ type: "directory",
+ children: [],
+ };
+
+ for (const file of files) {
+ const parts = file.filename.split("/");
+ let current = root;
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const isFile = i === parts.length - 1;
+
+ let child = current.children.find((node) => node.name === part);
+ if (!child) {
+ child = {
+ name: part,
+ path: parts.slice(0, i + 1).join("/"),
+ type: isFile ? "file" : "directory",
+ status: isFile ? file.status : undefined,
+ additions: isFile ? file.additions : undefined,
+ deletions: isFile ? file.deletions : undefined,
+ children: [],
+ };
+ current.children.push(child);
+ }
+ current = child;
+ }
+ }
+
+ function collapse(node: FileTreeNode): FileTreeNode {
+ if (
+ node.type === "directory" &&
+ node.children.length === 1 &&
+ node.children[0].type === "directory"
+ ) {
+ const child = node.children[0];
+ return collapse({
+ ...child,
+ name: `${node.name}/${child.name}`,
+ children: child.children,
+ });
+ }
+
+ return {
+ ...node,
+ children: node.children.map(collapse),
+ };
+ }
+
+ function sortTree(nodes: FileTreeNode[]): FileTreeNode[] {
+ return nodes
+ .map((node) => ({ ...node, children: sortTree(node.children) }))
+ .sort((a, b) => {
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ }
+
+ return sortTree(root.children.map(collapse));
+}
+
+export function buildPatchString(file: PullFile): string {
+ if (!file.patch) return "";
+ const source = file.previousFilename ?? file.filename;
+ const header = `diff --git a/${source} b/${file.filename}\n--- a/${source}\n+++ b/${file.filename}\n`;
+ return header + file.patch;
+}
+
+export function encodeFileId(filename: string): string {
+ return `diff-${filename.replaceAll("/", "-").replaceAll(".", "-")}`;
+}
diff --git a/apps/dashboard/src/lib/format-relative-time.ts b/apps/dashboard/src/lib/format-relative-time.ts
new file mode 100644
index 0000000..6c3d312
--- /dev/null
+++ b/apps/dashboard/src/lib/format-relative-time.ts
@@ -0,0 +1,14 @@
+export function formatRelativeTime(dateStr: string): string {
+ const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
+ if (seconds < 60) return "just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months}mo ago`;
+ const years = Math.floor(months / 12);
+ return `${years}y ago`;
+}
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx
index f6e7b66..323fc6d 100644
--- a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx
@@ -1,25 +1,7 @@
-import { IssuesIcon } from "@diffkit/icons";
-import { Markdown } from "@diffkit/ui/components/markdown";
-import { Skeleton } from "@diffkit/ui/components/skeleton";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@diffkit/ui/components/tooltip";
-import { cn } from "@diffkit/ui/lib/utils";
-import { useQuery } from "@tanstack/react-query";
-import { createFileRoute, Link } from "@tanstack/react-router";
-import { useState } from "react";
-import { LabelsSection } from "#/components/labels-section";
-import { formatRelativeTime } from "#/components/pulls/pull-request-row";
-import {
- githubIssuePageQueryOptions,
- githubQueryKeys,
-} from "#/lib/github.query";
-import type { GitHubActor, IssueDetail } from "#/lib/github.types";
+import { createFileRoute } from "@tanstack/react-router";
+import { IssueDetailPage } from "#/components/issues/detail/issue-detail-page";
+import { githubIssuePageQueryOptions } from "#/lib/github.query";
import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo";
-import { useHasMounted } from "#/lib/use-has-mounted";
-import { useRegisterTab } from "#/lib/use-register-tab";
export const Route = createFileRoute(
"/_protected/$owner/$repo/issues/$issueId",
@@ -60,470 +42,3 @@ export const Route = createFileRoute(
},
component: IssueDetailPage,
});
-
-function getIssueStateConfig(issue: IssueDetail) {
- if (issue.state === "closed") {
- if (issue.stateReason === "not_planned") {
- return {
- color: "text-muted-foreground",
- label: "Closed",
- badgeClass: "bg-muted text-muted-foreground",
- };
- }
- return {
- color: "text-purple-500",
- label: "Closed",
- badgeClass: "bg-purple-500/10 text-purple-500",
- };
- }
- return {
- color: "text-green-500",
- label: "Open",
- badgeClass: "bg-green-500/10 text-green-500",
- };
-}
-
-function IssueDetailPage() {
- const { user } = Route.useRouteContext();
- const { owner, repo, issueId } = Route.useParams();
- const issueNumber = Number(issueId);
- const scope = { userId: user.id };
- const hasMounted = useHasMounted();
-
- const pageQuery = useQuery({
- ...githubIssuePageQueryOptions(scope, { owner, repo, issueNumber }),
- enabled: hasMounted,
- });
-
- const issue = pageQuery.data?.detail;
- const comments = pageQuery.data?.comments;
-
- useRegisterTab(
- issue
- ? {
- type: "issue",
- title: issue.title,
- number: issue.number,
- url: `/${owner}/${repo}/issues/${issueId}`,
- repo: `${owner}/${repo}`,
- iconColor: getIssueStateConfig(issue).color,
- }
- : null,
- );
-
- if (pageQuery.error) throw pageQuery.error;
- if (!issue) return ;
-
- const stateConfig = getIssueStateConfig(issue);
-
- return (
-
-
- {/* Left: Issue content */}
-
- {/* Header */}
-
-
-
- Issues
-
- /
-
- {owner}/{repo}
-
- /
- #{issue.number}
-
-
-
-
-
-
-
-
- {issue.title}
-
-
-
- {stateConfig.label}
-
- {issue.author && (
-
-
-
- {issue.author.login}
-
- opened {formatRelativeTime(issue.createdAt)}
-
- )}
-
-
-
-
-
- {/* Body */}
- {issue.body ? (
-
- {issue.body}
-
- ) : (
-
-
- No description provided.
-
-
- )}
-
- {/* Activity / Comments */}
-
-
-
Activity
- {comments && (
-
- {comments.length}
-
- )}
-
-
- {pageQuery.isFetching && !comments && (
-
- )}
-
- {comments && comments.length === 0 && (
-
- No comments yet.
-
- )}
-
- {comments && comments.length > 0 && (
-
- {comments.map((comment, i) => (
-
-
- {comment.author ? (
-

- ) : (
-
- )}
-
- {comment.author?.login ?? "Unknown"}
-
-
- {formatRelativeTime(comment.createdAt)}
-
-
-
- {comment.body}
-
-
- ))}
-
- )}
-
- {/* Comment input */}
-
-
-
-
-
-
- {/* Right sidebar: Metadata */}
-
-
-
- );
-}
-
-function SidebarSection({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
-
- {title}
-
- {children}
-
- );
-}
-
-function DetailRow({
- label,
- children,
-}: {
- label: string;
- children: React.ReactNode;
-}) {
- return (
-
- {label}
- {children}
-
- );
-}
-
-function ParticipantsList({
- issue,
- comments,
-}: {
- issue: IssueDetail;
- comments: Array<{ author: GitHubActor | null }>;
-}) {
- const seen = new Set();
- const participants: GitHubActor[] = [];
-
- const addActor = (actor: GitHubActor | null) => {
- if (actor && !seen.has(actor.login)) {
- seen.add(actor.login);
- participants.push(actor);
- }
- };
-
- addActor(issue.author);
- for (const assignee of issue.assignees) {
- addActor(assignee);
- }
- for (const comment of comments) {
- addActor(comment.author);
- }
-
- if (participants.length === 0) {
- return No participants yet
;
- }
-
- return (
-
- {participants.map((actor, i) => (
-
-
-
0 ? { marginLeft: -6 } : undefined}
- />
-
- {actor.login}
-
- ))}
-
- );
-}
-
-function CommentBox() {
- const [value, setValue] = useState("");
-
- return (
-
- );
-}
-
-function IssueDetailPageSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {["activity-1", "activity-2", "activity-3"].map((key) => (
-
- ))}
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx
index 990afc9..ca5e369 100644
--- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx
@@ -1,76 +1,7 @@
-import {
- CalendarIcon,
- CheckIcon,
- ClockIcon,
- CloseIcon,
- CommentIcon,
- CopyIcon,
- EditIcon,
- FileIcon,
- GitCommitIcon,
- GitMergeIcon,
- GitPullRequestClosedIcon,
- GitPullRequestDraftIcon,
- GitPullRequestIcon,
- MessageIcon,
- MoreHorizontalIcon,
- PlusSignIcon,
- ReviewsIcon,
- SearchIcon,
-} from "@diffkit/icons";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@diffkit/ui/components/dropdown-menu";
-import { highlightCode, Markdown } from "@diffkit/ui/components/markdown";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@diffkit/ui/components/popover";
-import { Skeleton } from "@diffkit/ui/components/skeleton";
-import { Spinner } from "@diffkit/ui/components/spinner";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@diffkit/ui/components/tooltip";
-import { cn } from "@diffkit/ui/lib/utils";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { createFileRoute, Link } from "@tanstack/react-router";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { LabelsSection } from "#/components/labels-section";
-import { formatRelativeTime } from "#/components/pulls/pull-request-row";
-import {
- removeReviewRequest,
- requestPullReviewers,
- updatePullBody,
- updatePullBranch,
-} from "#/lib/github.functions";
-import {
- githubOrgTeamsQueryOptions,
- githubPullPageQueryOptions,
- githubPullStatusQueryOptions,
- githubQueryKeys,
- githubRepoCollaboratorsQueryOptions,
- githubViewerQueryOptions,
-} from "#/lib/github.query";
-import type {
- GitHubActor,
- PullComment,
- PullCommit,
- PullDetail,
- PullPageData,
- PullStatus,
-} from "#/lib/github.types";
-import { githubCachePolicy } from "#/lib/github-cache-policy";
+import { createFileRoute } from "@tanstack/react-router";
+import { PullDetailPage } from "#/components/pulls/detail/pull-detail-page";
+import { githubPullPageQueryOptions } from "#/lib/github.query";
import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo";
-import { useHasMounted } from "#/lib/use-has-mounted";
-import { useOptimisticMutation } from "#/lib/use-optimistic-mutation";
-import { useRegisterTab } from "#/lib/use-register-tab";
export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({
loader: async ({ context, params }) => {
@@ -109,1771 +40,3 @@ export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({
},
component: PullDetailPage,
});
-
-function getPrStateConfig(pr: PullDetail) {
- if (pr.isDraft) {
- return {
- icon: GitPullRequestDraftIcon,
- color: "text-muted-foreground",
- label: "Draft",
- badgeClass: "bg-muted text-muted-foreground",
- };
- }
- if (pr.isMerged || pr.mergedAt) {
- return {
- icon: GitMergeIcon,
- color: "text-purple-500",
- label: "Merged",
- badgeClass: "bg-purple-500/10 text-purple-500",
- };
- }
- if (pr.state === "closed") {
- return {
- icon: GitPullRequestClosedIcon,
- color: "text-red-500",
- label: "Closed",
- badgeClass: "bg-red-500/10 text-red-500",
- };
- }
- return {
- icon: GitPullRequestIcon,
- color: "text-green-500",
- label: "Open",
- badgeClass: "bg-green-500/10 text-green-500",
- };
-}
-
-function PullDetailPage() {
- const { user } = Route.useRouteContext();
- const { owner, repo, pullId } = Route.useParams();
- const pullNumber = Number(pullId);
- const scope = { userId: user.id };
- const hasMounted = useHasMounted();
-
- const pageQuery = useQuery({
- ...githubPullPageQueryOptions(scope, { owner, repo, pullNumber }),
- enabled: hasMounted,
- });
-
- const statusQuery = useQuery({
- ...githubPullStatusQueryOptions(scope, { owner, repo, pullNumber }),
- enabled: hasMounted && pageQuery.data?.detail != null,
- refetchOnWindowFocus: "always",
- refetchInterval: githubCachePolicy.status.staleTimeMs,
- });
-
- const viewerQuery = useQuery({
- ...githubViewerQueryOptions(scope),
- enabled: hasMounted,
- });
-
- const pr = pageQuery.data?.detail;
- const comments = pageQuery.data?.comments;
- const commits = pageQuery.data?.commits;
- const status = statusQuery.data ?? null;
- const viewer = viewerQuery.data ?? null;
-
- useRegisterTab(
- pr
- ? {
- type: "pull",
- title: pr.title,
- number: pr.number,
- url: `/${owner}/${repo}/pull/${pullId}`,
- repo: `${owner}/${repo}`,
- iconColor: getPrStateConfig(pr).color,
- }
- : null,
- );
-
- if (pageQuery.error) throw pageQuery.error;
- if (!pr) return ;
-
- const stateConfig = getPrStateConfig(pr);
- const StateIcon = stateConfig.icon;
-
- return (
-
-
- {/* Left: PR content */}
-
- {/* Header */}
-
-
-
- Pull Requests
-
- /
-
- {owner}/{repo}
-
- /
- #{pr.number}
-
-
-
-
-
-
-
-
- {pr.title}
-
-
-
- {stateConfig.label}
-
- {pr.author && (
- <>
-

-
- {pr.author.login}
-
-
wants to merge into
-
-
from
-
- >
- )}
-
-
-
-
-
-
- {/* Review request banner */}
- {viewer &&
- pr.requestedReviewers.some((r) => r.login === viewer.login) && (
-
-
-
- Your review has been requested
-
-
- Review changes
-
-
- )}
-
- {/* Stats bar */}
-
-
-
-
- {pr.commits}
- {" "}
- {pr.commits === 1 ? "commit" : "commits"}
-
- ·
-
-
-
- {pr.changedFiles}
- {" "}
- {pr.changedFiles === 1 ? "file" : "files"} changed
-
-
-
-
- +{pr.additions}
-
-
- -{pr.deletions}
-
-
-
- {!pr.isMerged &&
- !(
- viewer &&
- pr.requestedReviewers.some((r) => r.login === viewer.login)
- ) && (
-
- Review changes
-
- )}
-
-
-
-
- {/* Body */}
-
-
- {/* Activity */}
-
-
-
Activity
- {comments && commits && (
-
- {comments.length + commits.length}
-
- )}
-
-
- {pageQuery.isFetching && !comments && (
-
- )}
-
- {comments &&
- commits &&
- comments.length === 0 &&
- commits.length === 0 && (
-
- No activity yet.
-
- )}
-
-
-
- {/* Status card */}
- {!pr.isMerged && pr.state !== "closed" && (
-
- {status ? (
-
- ) : (
-
- )}
-
- )}
-
- {/* Comment input */}
-
-
-
-
-
-
- {/* Right sidebar: Metadata */}
-
-
-
- );
-}
-
-function SidebarSection({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
-
- {title}
-
- {children}
-
- );
-}
-
-function PullBodySection({
- pr,
- owner,
- repo,
- pullNumber,
- isAuthor,
- scope,
-}: {
- pr: PullDetail;
- owner: string;
- repo: string;
- pullNumber: number;
- isAuthor: boolean;
- scope: { userId: string };
-}) {
- const { mutate } = useOptimisticMutation();
- const [isEditing, setIsEditing] = useState(false);
- const [editTab, setEditTab] = useState<"edit" | "preview">("edit");
- const [draft, setDraft] = useState(pr.body);
- const [isSaving, setIsSaving] = useState(false);
- const editorRef = useRef(null);
-
- const insertMarkdown = useCallback(
- (before: string, after = "", placeholder = "") => {
- const ta = editorRef.current;
- if (!ta) return;
- const start = ta.selectionStart;
- const end = ta.selectionEnd;
- const selected = draft.slice(start, end);
- const text = selected || placeholder;
- const newValue = `${draft.slice(0, start)}${before}${text}${after}${draft.slice(end)}`;
- setDraft(newValue);
- requestAnimationFrame(() => {
- ta.focus();
- const cursorStart = start + before.length;
- ta.setSelectionRange(cursorStart, cursorStart + text.length);
- });
- },
- [draft],
- );
-
- const handleEditorKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- const mod = e.metaKey || e.ctrlKey;
- if (!mod) return;
-
- const shortcuts: Record void> = {
- b: () => insertMarkdown("**", "**", "bold"),
- i: () => insertMarkdown("_", "_", "italic"),
- e: () => insertMarkdown("`", "`", "code"),
- k: () => insertMarkdown("[", "](url)", "text"),
- h: () => insertMarkdown("### ", "", "heading"),
- };
-
- // Shift combos
- if (e.shiftKey) {
- const shiftShortcuts: Record void> = {
- ".": () => insertMarkdown("> ", "", "quote"),
- "8": () => insertMarkdown("- ", "", "item"),
- "7": () => insertMarkdown("1. ", "", "item"),
- };
- const action = shiftShortcuts[e.key];
- if (action) {
- e.preventDefault();
- action();
- }
- return;
- }
-
- const action = shortcuts[e.key];
- if (action) {
- e.preventDefault();
- action();
- }
- },
- [insertMarkdown],
- );
-
- const pageQueryKey = githubQueryKeys.pulls.page(scope, {
- owner,
- repo,
- pullNumber,
- });
-
- const startEditing = () => {
- setDraft(pr.body);
- setEditTab("edit");
- setIsEditing(true);
- };
-
- const cancelEditing = () => {
- setIsEditing(false);
- };
-
- const saveBody = async () => {
- setIsSaving(true);
- try {
- await mutate({
- mutationFn: () =>
- updatePullBody({
- data: { owner, repo, pullNumber, body: draft },
- }),
- updates: [
- {
- queryKey: pageQueryKey,
- updater: (prev: PullPageData) => ({
- ...prev,
- detail: prev.detail
- ? { ...prev.detail, body: draft }
- : prev.detail,
- }),
- },
- ],
- });
- setIsEditing(false);
- } finally {
- setIsSaving(false);
- }
- };
-
- if (isEditing) {
- return (
-
-
-
-
-
-
- {editTab === "edit" && (
-
-
-
insertMarkdown("### ", "", "heading")}
- >
-
-
-
insertMarkdown("**", "**", "bold")}
- >
-
-
-
insertMarkdown("_", "_", "italic")}
- >
-
-
-
-
insertMarkdown("`", "`", "code")}
- >
-
-
-
insertMarkdown("[", "](url)", "text")}
- >
-
-
-
-
insertMarkdown("> ", "", "quote")}
- >
-
-
-
-
-
insertMarkdown("- ", "", "item")}
- >
-
-
-
insertMarkdown("1. ", "", "item")}
- >
-
-
-
insertMarkdown("- [ ] ", "", "task")}
- >
-
-
-
-
-
- )}
-
-
- {editTab === "edit" ? (
-
- ) : (
-
- {draft ? (
-
{draft}
- ) : (
-
- Nothing to preview
-
- )}
-
- )}
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- {pr.body && (
- {
- void navigator.clipboard.writeText(pr.body);
- }}
- >
-
- Copy as Markdown
-
- )}
- {isAuthor && (
-
-
- Edit
-
- )}
-
-
- {pr.body ? (
-
{pr.body}
- ) : (
-
- No description provided.
-
- )}
-
- );
-}
-
-function ReviewersSection({
- pr,
- owner,
- repo,
- pullNumber,
- scope,
-}: {
- pr: PullDetail;
- owner: string;
- repo: string;
- pullNumber: number;
- scope: { userId: string };
-}) {
- const { mutate } = useOptimisticMutation();
- const [pickerOpen, setPickerOpen] = useState(false);
- const [search, setSearch] = useState("");
-
- const collaboratorsQuery = useQuery({
- ...githubRepoCollaboratorsQueryOptions(scope, { owner, repo }),
- enabled: pickerOpen,
- });
- const teamsQuery = useQuery({
- ...githubOrgTeamsQueryOptions(scope, owner),
- enabled: pickerOpen,
- });
- const collaborators = collaboratorsQuery.data ?? [];
- const teams = teamsQuery.data ?? [];
- const isLoading = collaboratorsQuery.isLoading || teamsQuery.isLoading;
-
- const isOpen = !pr.isMerged && pr.state !== "closed";
-
- const requestedLogins = useMemo(
- () => new Set(pr.requestedReviewers.map((r) => r.login)),
- [pr.requestedReviewers],
- );
-
- const requestedTeamSlugs = useMemo(
- () => new Set(pr.requestedTeams.map((t) => t.slug)),
- [pr.requestedTeams],
- );
-
- const candidates = useMemo(() => {
- const authorLogin = pr.author?.login;
- return collaborators.filter((c) => c.login !== authorLogin);
- }, [collaborators, pr.author?.login]);
-
- const filteredUsers = useMemo(() => {
- if (!search) return candidates;
- const q = search.toLowerCase();
- return candidates.filter((c) => c.login.toLowerCase().includes(q));
- }, [candidates, search]);
-
- const filteredTeams = useMemo(() => {
- if (!search) return teams;
- const q = search.toLowerCase();
- return teams.filter(
- (t) =>
- t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q),
- );
- }, [teams, search]);
-
- const pageQueryKey = githubQueryKeys.pulls.page(scope, {
- owner,
- repo,
- pullNumber,
- });
-
- const toggleReviewer = (login: string) => {
- const isRequested = requestedLogins.has(login);
- const collaborator = collaborators.find((c) => c.login === login);
-
- mutate({
- mutationFn: () =>
- isRequested
- ? removeReviewRequest({
- data: { owner, repo, pullNumber, reviewers: [login] },
- })
- : requestPullReviewers({
- data: { owner, repo, pullNumber, reviewers: [login] },
- }),
- updates: [
- {
- queryKey: pageQueryKey,
- updater: (prev: PullPageData) => ({
- ...prev,
- detail: prev.detail
- ? {
- ...prev.detail,
- requestedReviewers: isRequested
- ? prev.detail.requestedReviewers.filter(
- (r) => r.login !== login,
- )
- : [
- ...prev.detail.requestedReviewers,
- {
- login,
- avatarUrl: collaborator?.avatarUrl ?? "",
- url: `https://github.com/${login}`,
- type: "User",
- },
- ],
- }
- : prev.detail,
- }),
- },
- ],
- });
- };
-
- const toggleTeam = (slug: string) => {
- const isRequested = requestedTeamSlugs.has(slug);
- const team = teams.find((t) => t.slug === slug);
-
- mutate({
- mutationFn: () =>
- isRequested
- ? removeReviewRequest({
- data: { owner, repo, pullNumber, teamReviewers: [slug] },
- })
- : requestPullReviewers({
- data: { owner, repo, pullNumber, teamReviewers: [slug] },
- }),
- updates: [
- {
- queryKey: pageQueryKey,
- updater: (prev: PullPageData) => ({
- ...prev,
- detail: prev.detail
- ? {
- ...prev.detail,
- requestedTeams: isRequested
- ? prev.detail.requestedTeams.filter((t) => t.slug !== slug)
- : [
- ...prev.detail.requestedTeams,
- {
- slug,
- name: team?.name ?? slug,
- url: `https://github.com/orgs/${owner}/teams/${slug}`,
- },
- ],
- }
- : prev.detail,
- }),
- },
- ],
- });
- };
-
- const hasReviewers =
- pr.requestedReviewers.length > 0 || pr.requestedTeams.length > 0;
-
- const [focusedIndex, setFocusedIndex] = useState(-1);
- const listRef = useRef(null);
-
- type ReviewerItem =
- | { kind: "team"; slug: string }
- | { kind: "user"; login: string };
-
- const flatItems = useMemo(() => {
- const items: ReviewerItem[] = [];
- for (const t of filteredTeams) items.push({ kind: "team", slug: t.slug });
- for (const c of filteredUsers) items.push({ kind: "user", login: c.login });
- return items;
- }, [filteredTeams, filteredUsers]);
-
- const scrollToFocused = useCallback((index: number) => {
- const el = listRef.current?.querySelector(`[data-index="${index}"]`);
- if (el) {
- el.scrollIntoView({ block: "nearest" });
- }
- }, []);
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (flatItems.length === 0) return;
-
- if (e.key === "ArrowDown") {
- e.preventDefault();
- const next = focusedIndex < flatItems.length - 1 ? focusedIndex + 1 : 0;
- setFocusedIndex(next);
- scrollToFocused(next);
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- const next = focusedIndex > 0 ? focusedIndex - 1 : flatItems.length - 1;
- setFocusedIndex(next);
- scrollToFocused(next);
- } else if (e.key === "Enter") {
- e.preventDefault();
- if (focusedIndex < 0) return;
- const item = flatItems[focusedIndex];
- if (item.kind === "team") {
- toggleTeam(item.slug);
- } else {
- toggleReviewer(item.login);
- }
- }
- };
-
- return (
-
-
-
- Reviewers
-
- {isOpen && (
-
{
- setPickerOpen(open);
- if (!open) {
- setSearch("");
- setFocusedIndex(-1);
- }
- }}
- >
-
-
-
-
-
-
- {
- setSearch(e.target.value);
- setFocusedIndex(-1);
- }}
- onKeyDown={handleKeyDown}
- placeholder="Search people and teams..."
- className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
- />
-
-
- {isLoading ? (
-
- Loading…
-
- ) : filteredUsers.length === 0 && filteredTeams.length === 0 ? (
-
- No results found
-
- ) : (
- <>
- {filteredTeams.length > 0 && (
- <>
-
- Teams
-
- {filteredTeams.map((t, i) => {
- const isSelected = requestedTeamSlugs.has(t.slug);
- return (
-
- );
- })}
- >
- )}
- {filteredUsers.length > 0 && (
- <>
-
- People
-
- {filteredUsers.map((c, i) => {
- const idx = filteredTeams.length + i;
- const isSelected = requestedLogins.has(c.login);
- return (
-
- );
- })}
- >
- )}
- >
- )}
-
-
-
- )}
-
- {hasReviewers ? (
-
- {pr.requestedTeams.map((team) => (
-
-
- T
-
-
- {team.name}
-
- {isOpen && (
-
- )}
-
- ))}
- {pr.requestedReviewers.map((reviewer) => (
-
-

-
- {reviewer.login}
-
- {isOpen && (
-
- )}
-
- ))}
-
- ) : (
-
No reviewers requested
- )}
-
- );
-}
-
-function DetailRow({
- icon: Icon,
- label,
- children,
-}: {
- icon: React.FC<{ size?: number; strokeWidth?: number }>;
- label: string;
- children: React.ReactNode;
-}) {
- return (
-
-
-
- {label}
-
- {children}
-
- );
-}
-
-function ParticipantsList({
- pr,
- comments,
- commits,
-}: {
- pr: PullDetail;
- comments: Array<{ author: GitHubActor | null }>;
- commits: Array<{ author: GitHubActor | null }>;
-}) {
- const seen = new Set();
- const participants: GitHubActor[] = [];
-
- const addActor = (actor: GitHubActor | null) => {
- if (actor && !seen.has(actor.login)) {
- seen.add(actor.login);
- participants.push(actor);
- }
- };
-
- addActor(pr.author);
- for (const comment of comments) {
- addActor(comment.author);
- }
- for (const commit of commits) {
- addActor(commit.author);
- }
-
- if (participants.length === 0) {
- return No participants yet
;
- }
-
- return (
-
- {participants.map((actor, i) => (
-
-
-
0 ? { marginLeft: -6 } : undefined}
- />
-
- {actor.login}
-
- ))}
-
- );
-}
-
-function MergeStatusCard({
- status,
- owner,
- repo,
- pullNumber,
-}: {
- status: PullStatus;
- owner: string;
- repo: string;
- pullNumber: number;
-}) {
- const {
- checks,
- reviews,
- mergeable,
- mergeableState,
- behindBy,
- baseRefName,
- canUpdateBranch,
- } = status;
- const [isUpdating, setIsUpdating] = useState(false);
-
- const approvedReviews = reviews.filter((r) => r.state === "APPROVED");
- const changesRequested = reviews.filter(
- (r) => r.state === "CHANGES_REQUESTED",
- );
- const pendingReviewers = reviews.filter((r) => r.state === "PENDING");
-
- const hasReviewIssue =
- changesRequested.length > 0 || pendingReviewers.length > 0;
- const allChecksPassed =
- checks.total > 0 && checks.failed === 0 && checks.pending === 0;
- const hasCheckFailures = checks.failed > 0;
- const hasChecksPending = checks.pending > 0;
- const isBehind = behindBy !== null && behindBy > 0;
-
- const isMergeBlocked = mergeableState === "blocked" || mergeable === false;
-
- return (
-
- {/* Reviews */}
- 0 ? (
-
- ) : approvedReviews.length > 0 && !hasReviewIssue ? (
-
- ) : (
-
- )
- }
- title={
- changesRequested.length > 0
- ? "Changes requested"
- : approvedReviews.length > 0
- ? `${approvedReviews.length} approving review${approvedReviews.length > 1 ? "s" : ""}`
- : "Review required"
- }
- description={
- changesRequested.length > 0
- ? `${changesRequested.map((r) => r.author?.login).join(", ")} requested changes`
- : approvedReviews.length > 0 && !hasReviewIssue
- ? "All required reviews have been provided"
- : "Code owner review required by reviewers with write access."
- }
- />
-
- {/* Checks */}
- {checks.total > 0 && (
-
- ) : hasCheckFailures ? (
-
- ) : (
-
- )
- }
- title={
- allChecksPassed
- ? "All checks have passed"
- : hasCheckFailures
- ? `${checks.failed} failing check${checks.failed > 1 ? "s" : ""}`
- : `${checks.pending} pending check${checks.pending > 1 ? "s" : ""}`
- }
- description={
- `${checks.skipped > 0 ? `${checks.skipped} skipped, ` : ""}${checks.passed} successful check${checks.passed !== 1 ? "s" : ""}` +
- (hasChecksPending ? `, ${checks.pending} pending` : "") +
- (hasCheckFailures ? `, ${checks.failed} failing` : "")
- }
- />
- )}
-
- {/* Behind base */}
- {isBehind && (
- }
- title="This branch is out-of-date with the base branch"
- description={`Merge the latest changes from ${baseRefName} into this branch.`}
- action={
- canUpdateBranch ? (
-
- ) : undefined
- }
- />
- )}
-
- {/* Merge status */}
-
- ) : (
-
- )
- }
- title={isMergeBlocked ? "Merging is blocked" : "Ready to merge"}
- description={
- isMergeBlocked
- ? "All required conditions have not been met."
- : "All required conditions have been satisfied."
- }
- isLast
- />
-
- );
-}
-
-function StatusRow({
- icon,
- title,
- description,
- action,
- isLast,
-}: {
- icon: React.ReactNode;
- title: string;
- description: string;
- action?: React.ReactNode;
- isLast?: boolean;
-}) {
- return (
-
-
{icon}
-
-
{title}
-
{description}
-
- {action &&
{action}
}
-
- );
-}
-
-const DIFF_BOX_COUNT = 5;
-
-function CopyBadge({
- value,
- canTruncate,
-}: {
- value: string;
- canTruncate?: boolean;
-}) {
- const [copied, setCopied] = useState(false);
- const timeoutRef = useRef>(undefined);
-
- const handleClick = useCallback(() => {
- navigator.clipboard.writeText(value);
- setCopied(true);
- clearTimeout(timeoutRef.current);
- timeoutRef.current = setTimeout(() => setCopied(false), 1500);
- }, [value]);
-
- return (
-
-
-
-
- Copied!
-
- );
-}
-
-function MdToolbarButton({
- label,
- shortcut,
- onClick,
- children,
-}: {
- label: string;
- shortcut?: string;
- onClick: () => void;
- children: React.ReactNode;
-}) {
- return (
-
-
-
-
-
-
- {label}
- {shortcut && (
-
- {shortcut}
-
- )}
-
-
-
- );
-}
-
-function HighlightedMarkdownEditor({
- value,
- onChange,
- placeholder,
- textareaRef: externalRef,
- onKeyDown,
-}: {
- value: string;
- onChange: (value: string) => void;
- placeholder?: string;
- textareaRef?: React.RefObject;
- onKeyDown?: React.KeyboardEventHandler;
-}) {
- const [highlightedHtml, setHighlightedHtml] = useState("");
- const containerRef = useRef(null);
- const internalRef = useRef(null);
- const textareaRef = externalRef || internalRef;
-
- useEffect(() => {
- let cancelled = false;
- if (!value) {
- setHighlightedHtml("");
- return;
- }
- highlightCode(value, "markdown").then((html) => {
- if (!cancelled) setHighlightedHtml(html);
- });
- return () => {
- cancelled = true;
- };
- }, [value]);
-
- const highlightRef = useRef(null);
-
- const syncScroll = () => {
- if (highlightRef.current && textareaRef.current) {
- highlightRef.current.scrollTop = textareaRef.current.scrollTop;
- highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
- }
- };
-
- return (
-
- {/* Highlighted layer */}
-
- {/* Editable textarea */}
-
- );
-}
-
-function DiffBoxes({
- additions,
- deletions,
-}: {
- additions: number;
- deletions: number;
-}) {
- const total = additions + deletions;
- const greenCount =
- total === 0 ? 0 : Math.round((additions / total) * DIFF_BOX_COUNT);
- const redCount = total === 0 ? 0 : DIFF_BOX_COUNT - greenCount;
-
- const boxes: string[] = [];
- for (let i = 0; i < greenCount; i++) boxes.push("bg-green-500");
- for (let i = 0; i < redCount; i++) boxes.push("bg-red-500");
- while (boxes.length < DIFF_BOX_COUNT) boxes.push("bg-muted-foreground/30");
-
- return (
-
- {boxes.map((color, i) => (
- // biome-ignore lint/suspicious/noArrayIndexKey: static decorative boxes, order never changes
-
- ))}
-
- );
-}
-
-function StatusDot({ color }: { color: string }) {
- return (
-
- );
-}
-
-function MergeStatusSkeleton() {
- return (
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
- );
-}
-
-function PullDetailPageSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[0, 1, 2].map((item) => (
-
- ))}
-
-
-
-
-
-
-
-
- );
-}
-
-function UpdateBranchButton({
- owner,
- repo,
- pullNumber,
- isUpdating,
- setIsUpdating,
-}: {
- owner: string;
- repo: string;
- pullNumber: number;
- isUpdating: boolean;
- setIsUpdating: (v: boolean) => void;
-}) {
- const queryClient = useQueryClient();
-
- const handleUpdate = async () => {
- setIsUpdating(true);
- try {
- const success = await updatePullBranch({
- data: { owner, repo, pullNumber },
- });
- if (success) {
- await queryClient.invalidateQueries({
- queryKey: ["github"],
- });
- }
- } finally {
- setIsUpdating(false);
- }
- };
-
- return (
-
- );
-}
-
-type TimelineItem =
- | { type: "comment"; date: string; data: PullComment }
- | { type: "commit"; date: string; data: PullCommit };
-
-function ActivityTimeline({
- comments,
- commits,
-}: {
- comments: PullComment[];
- commits: PullCommit[];
-}) {
- const items: TimelineItem[] = [
- ...comments.map((c) => ({
- type: "comment" as const,
- date: c.createdAt,
- data: c,
- })),
- ...commits.map((c) => ({
- type: "commit" as const,
- date: c.createdAt,
- data: c,
- })),
- ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
-
- if (items.length === 0) return null;
-
- return (
-
- {items.map((item, i) => {
- const prevType = i > 0 ? items[i - 1].type : null;
- const nextType = i < items.length - 1 ? items[i + 1].type : null;
- const isConsecutiveCommit =
- item.type === "commit" && prevType === "commit";
- const isLastInCommitRun =
- item.type === "commit" && nextType !== "commit";
-
- if (item.type === "comment") {
- const comment = item.data;
- return (
-
-
- {comment.author ? (
-

- ) : (
-
- )}
-
- {comment.author?.login ?? "Unknown"}
-
-
- {formatRelativeTime(comment.createdAt)}
-
-
-
- {comment.body}
-
-
- );
- }
-
- const commit = item.data;
- const firstLine = commit.message.split("\n")[0];
- return (
-
-
-
-
- {commit.author ? (
-

- ) : (
-
- )}
-
{firstLine}
-
- {commit.sha.slice(0, 7)}
-
-
- {formatRelativeTime(commit.createdAt)}
-
-
- );
- })}
-
- );
-}
-
-function CommentBox() {
- const [value, setValue] = useState("");
-
- return (
-
- );
-}
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx
index f7f722c..d5b07c6 100644
--- a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx
@@ -1,85 +1,11 @@
-import {
- CloseIcon,
- CommentIcon,
- FileIcon,
- FolderIcon,
- GitBranchIcon,
- GitMergeIcon,
- GitPullRequestClosedIcon,
- GitPullRequestDraftIcon,
- GitPullRequestIcon,
- SearchIcon,
- TickIcon,
-} from "@diffkit/icons";
-import { Markdown } from "@diffkit/ui/components/markdown";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@diffkit/ui/components/popover";
-import {
- ResizableHandle,
- ResizablePanel,
- ResizablePanelGroup,
-} from "@diffkit/ui/components/resizable";
-import { vercelDark, vercelLight } from "@diffkit/ui/lib/shiki-themes";
-import { cn } from "@diffkit/ui/lib/utils";
-import type { SelectedLineRange } from "@pierre/diffs";
-import type { DiffLineAnnotation, PatchDiffProps } from "@pierre/diffs/react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { createFileRoute, Link } from "@tanstack/react-router";
-import { useTheme } from "next-themes";
-import {
- type ComponentType,
- type LazyExoticComponent,
- lazy,
- Suspense,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import { formatRelativeTime } from "#/components/pulls/pull-request-row";
-import { submitPullReview } from "#/lib/github.functions";
+import { createFileRoute } from "@tanstack/react-router";
+import { ReviewPage } from "#/components/pulls/review/review-page";
import {
githubPullFilesQueryOptions,
githubPullPageQueryOptions,
githubPullReviewCommentsQueryOptions,
- githubQueryKeys,
} from "#/lib/github.query";
-import type {
- PullDetail,
- PullFile,
- PullReviewComment,
-} from "#/lib/github.types";
import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo";
-import { useHasMounted } from "#/lib/use-has-mounted";
-import { useRegisterTab } from "#/lib/use-register-tab";
-
-// Lazy-load PatchDiff so @pierre/diffs (which bundles all shiki language grammars)
-// is excluded from the server bundle, keeping it under the CF Workers 3 MiB limit.
-// During SSR, return a no-op component to avoid stubbed-shiki runtime errors.
-type ReviewPatchDiffComponent = ComponentType>;
-
-const PatchDiff: LazyExoticComponent = lazy(() =>
- import.meta.env.SSR
- ? Promise.resolve({
- default: (() => null) as ReviewPatchDiffComponent,
- })
- : import("@pierre/diffs/react").then((mod) => ({
- default: mod.PatchDiff as ReviewPatchDiffComponent,
- })),
-);
-
-// Register custom themes lazily on the client to avoid pulling shiki into the server bundle.
-// import.meta.env.SSR is statically replaced by Vite so the import is fully tree-shaken from SSR.
-if (!import.meta.env.SSR) {
- import("@pierre/diffs").then(({ registerCustomTheme }) => {
- registerCustomTheme("vercel-light", () => Promise.resolve(vercelLight));
- registerCustomTheme("vercel-dark", () => Promise.resolve(vercelDark));
- });
-}
export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")(
{
@@ -133,1157 +59,3 @@ export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")(
component: ReviewPage,
},
);
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-type PendingComment = {
- path: string;
- line: number;
- startLine?: number;
- side: "LEFT" | "RIGHT";
- startSide?: "LEFT" | "RIGHT";
- body: string;
-};
-
-type ReviewAnnotation = PullReviewComment | PendingComment;
-type ReviewEvent = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
-
-type FileTreeNode = {
- name: string;
- path: string;
- type: "file" | "directory";
- status?: PullFile["status"];
- additions?: number;
- deletions?: number;
- children: FileTreeNode[];
-};
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function getPrStateConfig(pr: PullDetail) {
- if (pr.isDraft) {
- return {
- icon: GitPullRequestDraftIcon,
- color: "text-muted-foreground",
- label: "Draft",
- badgeClass: "bg-muted text-muted-foreground",
- };
- }
- if (pr.isMerged || pr.mergedAt) {
- return {
- icon: GitMergeIcon,
- color: "text-purple-500",
- label: "Merged",
- badgeClass: "bg-purple-500/10 text-purple-500",
- };
- }
- if (pr.state === "closed") {
- return {
- icon: GitPullRequestClosedIcon,
- color: "text-red-500",
- label: "Closed",
- badgeClass: "bg-red-500/10 text-red-500",
- };
- }
- return {
- icon: GitPullRequestIcon,
- color: "text-green-500",
- label: "Open",
- badgeClass: "bg-green-500/10 text-green-500",
- };
-}
-
-function buildFileTree(files: PullFile[]): FileTreeNode[] {
- const root: FileTreeNode = {
- name: "",
- path: "",
- type: "directory",
- children: [],
- };
-
- for (const file of files) {
- const parts = file.filename.split("/");
- let current = root;
-
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
- const isFile = i === parts.length - 1;
-
- let child = current.children.find((c) => c.name === part);
- if (!child) {
- child = {
- name: part,
- path: parts.slice(0, i + 1).join("/"),
- type: isFile ? "file" : "directory",
- status: isFile ? file.status : undefined,
- additions: isFile ? file.additions : undefined,
- deletions: isFile ? file.deletions : undefined,
- children: [],
- };
- current.children.push(child);
- }
- current = child;
- }
- }
-
- // Collapse single-child directories
- function collapse(node: FileTreeNode): FileTreeNode {
- if (
- node.type === "directory" &&
- node.children.length === 1 &&
- node.children[0].type === "directory"
- ) {
- const child = node.children[0];
- return collapse({
- ...child,
- name: `${node.name}/${child.name}`,
- children: child.children,
- });
- }
- return {
- ...node,
- children: node.children.map(collapse),
- };
- }
-
- // Sort: directories first, then files, alphabetically
- function sortTree(nodes: FileTreeNode[]): FileTreeNode[] {
- return nodes
- .map((n) => ({ ...n, children: sortTree(n.children) }))
- .sort((a, b) => {
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
- }
-
- return sortTree(root.children.map(collapse));
-}
-
-function buildPatchString(file: PullFile): string {
- if (!file.patch) return "";
- const header = `diff --git a/${file.previousFilename ?? file.filename} b/${file.filename}\n--- a/${file.previousFilename ?? file.filename}\n+++ b/${file.filename}\n`;
- return header + file.patch;
-}
-
-function encodeFileId(filename: string): string {
- return `diff-${filename.replaceAll("/", "-").replaceAll(".", "-")}`;
-}
-
-// ---------------------------------------------------------------------------
-// Main Component
-// ---------------------------------------------------------------------------
-
-function ReviewPage() {
- const { user } = Route.useRouteContext();
- const { owner, repo, pullId } = Route.useParams();
- const pullNumber = Number(pullId);
- const scope = { userId: user.id };
- const hasMounted = useHasMounted();
- const queryClient = useQueryClient();
- const input = { owner, repo, pullNumber };
-
- const pageQuery = useQuery({
- ...githubPullPageQueryOptions(scope, input),
- enabled: hasMounted,
- });
-
- const filesQuery = useQuery({
- ...githubPullFilesQueryOptions(scope, input),
- enabled: hasMounted,
- });
-
- const reviewCommentsQuery = useQuery({
- ...githubPullReviewCommentsQueryOptions(scope, input),
- enabled: hasMounted,
- });
-
- const pr = pageQuery.data?.detail;
- const files = filesQuery.data ?? [];
- const reviewComments = reviewCommentsQuery.data ?? [];
-
- // Diff style state
- const [diffStyle, setDiffStyle] = useState<"unified" | "split">("unified");
-
- // Pending comments state
- const [pendingComments, setPendingComments] = useState([]);
- const [activeCommentForm, setActiveCommentForm] = useState<{
- path: string;
- line: number;
- startLine?: number;
- side: "LEFT" | "RIGHT";
- startSide?: "LEFT" | "RIGHT";
- } | null>(null);
-
- // Track selected line range for highlighting during gutter drag
- const [selectedLines, setSelectedLines] = useState(
- null,
- );
-
- // Active file tracking
- const [activeFile, setActiveFile] = useState(null);
- const diffPanelRef = useRef(null);
-
- // File tree filter
- const [fileFilter, setFileFilter] = useState("");
-
- // Tab registration
- useRegisterTab(
- pr
- ? {
- type: "review",
- title: pr.title,
- number: pr.number,
- url: `/${owner}/${repo}/review/${pullId}`,
- repo: `${owner}/${repo}`,
- iconColor: getPrStateConfig(pr).color,
- additions: pr.additions,
- deletions: pr.deletions,
- }
- : null,
- );
-
- // Build file tree
- const fileTree = useMemo(() => buildFileTree(files), [files]);
-
- // Filtered files for tree
- const filteredTree = useMemo(() => {
- if (!fileFilter) return fileTree;
- const lower = fileFilter.toLowerCase();
-
- function filterNodes(nodes: FileTreeNode[]): FileTreeNode[] {
- return nodes
- .map((node) => {
- if (node.type === "file") {
- return node.name.toLowerCase().includes(lower) ? node : null;
- }
- const filtered = filterNodes(node.children);
- return filtered.length > 0 ? { ...node, children: filtered } : null;
- })
- .filter(Boolean) as FileTreeNode[];
- }
-
- return filterNodes(fileTree);
- }, [fileTree, fileFilter]);
-
- // Scroll to file on click
- const scrollToFile = useCallback((filename: string) => {
- const element = document.getElementById(encodeFileId(filename));
- if (element) {
- element.scrollIntoView({ behavior: "smooth", block: "start" });
- setActiveFile(filename);
- }
- }, []);
-
- // Intersection observer for active file tracking
- useEffect(() => {
- const panel = diffPanelRef.current;
- if (!panel || files.length === 0) return;
-
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- if (entry.isIntersecting) {
- const filename = entry.target.getAttribute("data-filename");
- if (filename) setActiveFile(filename);
- }
- }
- },
- {
- root: panel,
- rootMargin: "-10% 0px -80% 0px",
- threshold: 0,
- },
- );
-
- for (const file of files) {
- const el = document.getElementById(encodeFileId(file.filename));
- if (el) observer.observe(el);
- }
-
- return () => observer.disconnect();
- }, [files]);
-
- // Build annotations map per file
- const annotationsByFile = useMemo(() => {
- const map = new Map[]>();
- for (const comment of reviewComments) {
- if (comment.line == null) continue;
- const existing = map.get(comment.path) ?? [];
- existing.push({
- side: comment.side === "LEFT" ? "deletions" : "additions",
- lineNumber: comment.line,
- metadata: comment,
- });
- map.set(comment.path, existing);
- }
- return map;
- }, [reviewComments]);
-
- // Add pending comment
- const addPendingComment = useCallback((comment: PendingComment) => {
- setPendingComments((prev) => [...prev, comment]);
- setActiveCommentForm(null);
- }, []);
-
- // Submit review
- const [isSubmitting, setIsSubmitting] = useState(false);
- const handleSubmitReview = useCallback(
- async (body: string, event: ReviewEvent) => {
- setIsSubmitting(true);
- try {
- const success = await submitPullReview({
- data: {
- owner,
- repo,
- pullNumber,
- body,
- event,
- comments: pendingComments.map((c) => ({
- path: c.path,
- line: c.line,
- side: c.side,
- body: c.body,
- ...(c.startLine != null && c.startLine !== c.line
- ? { startLine: c.startLine, startSide: c.startSide ?? c.side }
- : {}),
- })),
- },
- });
-
- if (success) {
- setPendingComments([]);
- queryClient.invalidateQueries({
- queryKey: githubQueryKeys.all,
- });
- }
- } finally {
- setIsSubmitting(false);
- }
- },
- [owner, repo, pullNumber, pendingComments, queryClient],
- );
-
- if (pageQuery.error) throw pageQuery.error;
-
- if (!pr) {
- return (
-
- );
- }
-
- const stateConfig = getPrStateConfig(pr);
- const StateIcon = stateConfig.icon;
-
- const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
- const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
-
- return (
-
- {/* Toolbar */}
-
-
-
-
#{pr.number}
-
-
-
-
-
-
-
-
-
-
-
- {files.length}
- {" "}
- {files.length === 1 ? "file" : "files"}
-
-
- +{totalAdditions}
-
-
- -{totalDeletions}
-
-
-
-
-
- {/* Diff style toggle */}
-
-
-
-
-
-
-
- {/* Submit review button */}
-
-
-
-
- {/* Main content: file tree + diffs */}
-
- {/* File tree sidebar */}
-
-
- {/* Filter */}
-
-
-
- setFileFilter(e.target.value)}
- className="ml-2 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
- />
-
-
-
- {/* Tree */}
-
- {filteredTree.map((node) => (
-
- ))}
-
-
- {/* Summary */}
-
- {files.length} {files.length === 1 ? "file" : "files"} changed
-
-
-
-
-
-
- {/* Diff panel */}
-
-
-
- {files.map((file) => (
-
c.path === file.filename,
- )}
- activeCommentForm={
- activeCommentForm?.path === file.filename
- ? activeCommentForm
- : null
- }
- selectedLines={
- activeCommentForm?.path === file.filename
- ? selectedLines
- : null
- }
- onGutterClick={(range) => {
- const side = range.side === "deletions" ? "LEFT" : "RIGHT";
- const isMultiLine = range.start !== range.end;
- setActiveCommentForm({
- path: file.filename,
- line: Math.max(range.start, range.end),
- side,
- ...(isMultiLine
- ? {
- startLine: Math.min(range.start, range.end),
- startSide:
- (range.endSide ?? range.side) === "deletions"
- ? "LEFT"
- : "RIGHT",
- }
- : {}),
- });
- setSelectedLines(range);
- }}
- onCancelComment={() => {
- setActiveCommentForm(null);
- setSelectedLines(null);
- }}
- onAddComment={(comment) => {
- addPendingComment(comment);
- setSelectedLines(null);
- }}
- />
- ))}
-
- {files.length === 0 && !filesQuery.isLoading && (
-
- No files changed in this pull request.
-
- )}
-
-
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// File Tree Node
-// ---------------------------------------------------------------------------
-
-function FileTreeNodeComponent({
- node,
- depth,
- activeFile,
- onFileClick,
-}: {
- node: FileTreeNode;
- depth: number;
- activeFile: string | null;
- onFileClick: (path: string) => void;
-}) {
- const [isOpen, setIsOpen] = useState(true);
-
- if (node.type === "directory") {
- return (
-
-
- {isOpen && (
-
- {node.children.map((child) => (
-
- ))}
-
- )}
-
- );
- }
-
- const isActive = activeFile === node.path;
-
- return (
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// File Diff Block
-// ---------------------------------------------------------------------------
-
-function FileDiffBlock({
- file,
- diffStyle,
- annotations,
- pendingComments,
- activeCommentForm,
- selectedLines,
- onGutterClick,
- onCancelComment,
- onAddComment,
-}: {
- file: PullFile;
- diffStyle: "unified" | "split";
- annotations: DiffLineAnnotation[];
- pendingComments: PendingComment[];
- activeCommentForm: {
- path: string;
- line: number;
- startLine?: number;
- side: "LEFT" | "RIGHT";
- startSide?: "LEFT" | "RIGHT";
- } | null;
- selectedLines: SelectedLineRange | null;
- onGutterClick: (range: SelectedLineRange) => void;
- onCancelComment: () => void;
- onAddComment: (comment: PendingComment) => void;
-}) {
- const [isCollapsed, setIsCollapsed] = useState(false);
- const { resolvedTheme } = useTheme();
- const isDark = resolvedTheme === "dark";
-
- // Combine existing review comments and pending comments into annotations
- const allAnnotations = useMemo(() => {
- const result: DiffLineAnnotation[] = [...annotations];
-
- for (const pending of pendingComments) {
- result.push({
- side: pending.side === "LEFT" ? "deletions" : "additions",
- lineNumber: pending.line,
- metadata: pending,
- });
- }
-
- // Add active comment form as an annotation
- if (activeCommentForm) {
- result.push({
- side: activeCommentForm.side === "LEFT" ? "deletions" : "additions",
- lineNumber: activeCommentForm.line,
- metadata: {
- path: activeCommentForm.path,
- line: activeCommentForm.line,
- startLine: activeCommentForm.startLine,
- side: activeCommentForm.side,
- startSide: activeCommentForm.startSide,
- body: "__FORM__",
- } satisfies PendingComment,
- });
- }
-
- return result;
- }, [annotations, pendingComments, activeCommentForm]);
-
- const mutedFg = isDark
- ? "oklch(0.705 0.015 286.067)"
- : "oklch(0.552 0.016 285.938)";
-
- const diffOptions = useMemo(
- () => ({
- diffStyle,
- theme: {
- dark: "vercel-dark" as const,
- light: "vercel-light" as const,
- },
- lineDiffType: "word" as const,
- hunkSeparators: "line-info" as const,
- overflow: "scroll" as const,
- disableFileHeader: true,
- enableGutterUtility: true,
- enableLineSelection: true,
- onGutterUtilityClick: onGutterClick,
- unsafeCSS: [
- `:host { color-scheme: ${isDark ? "dark" : "light"}; ${isDark ? "" : "--diffs-light-bg: oklch(0.967 0.001 286.375);"} }`,
- `:host { --diffs-font-family: 'Geist Mono Variable', 'SF Mono', ui-monospace, 'Cascadia Code', monospace; }`,
- `:host { --diffs-selection-base: ${mutedFg}; }`,
- `[data-utility-button] { background-color: ${mutedFg}; }`,
- `[data-line-annotation] { font-family: 'Inter Variable', 'Inter', 'Avenir Next', ui-sans-serif, system-ui, sans-serif; }`,
- `[data-line-annotation] code { font-family: var(--diffs-font-family, var(--diffs-font-fallback)); }`,
- isDark
- ? `:host { --diffs-bg-addition-override: color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base)); --diffs-bg-addition-number-override: color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-addition-base)); --diffs-bg-addition-emphasis-override: color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-addition-base)); --diffs-bg-deletion-override: color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)); --diffs-bg-deletion-number-override: color-mix(in lab, var(--diffs-bg) 88%, var(--diffs-deletion-base)); --diffs-bg-deletion-emphasis-override: color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)); }`
- : `:host { --diffs-bg-addition-override: color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-addition-base)); --diffs-bg-addition-number-override: color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-addition-base)); --diffs-bg-deletion-override: color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-deletion-base)); --diffs-bg-deletion-number-override: color-mix(in lab, var(--diffs-bg) 78%, var(--diffs-deletion-base)); }`,
- ].join("\n"),
- }),
- [diffStyle, onGutterClick, isDark, mutedFg],
- );
-
- if (!file.patch) {
- return (
-
-
setIsCollapsed(!isCollapsed)}
- />
- {!isCollapsed && (
-
- Binary file or diff too large to display
-
- )}
-
- );
- }
-
- const patchString = buildPatchString(file);
-
- return (
-
-
setIsCollapsed(!isCollapsed)}
- />
- {!isCollapsed && (
-
-
- ,
- ) => {
- const data = annotation.metadata as
- | PendingComment
- | PullReviewComment
- | null;
- if (!data) return null;
-
- // Pending comment form
- if ("body" in data && data.body === "__FORM__") {
- const formData = data as PendingComment;
- return (
-
- onAddComment({
- path: file.filename,
- line: formData.line,
- startLine: formData.startLine,
- side: formData.side,
- startSide: formData.startSide,
- body,
- })
- }
- onCancel={onCancelComment}
- />
- );
- }
-
- // Pending comment display
- if ("body" in data && !("id" in data)) {
- return (
-
- );
- }
-
- // Existing review comment
- if ("id" in data) {
- return (
-
- );
- }
-
- return null;
- }}
- />
-
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// File Header
-// ---------------------------------------------------------------------------
-
-function FileHeader({
- file,
- isCollapsed,
- onToggleCollapse,
-}: {
- file: PullFile;
- isCollapsed: boolean;
- onToggleCollapse: () => void;
-}) {
- return (
-
-
-
-
- {file.previousFilename && file.previousFilename !== file.filename ? (
- <>
-
- {file.previousFilename}
-
- →
- {file.filename}
- >
- ) : (
- file.filename
- )}
-
-
-
- {file.additions > 0 && (
- +{file.additions}
- )}
- {file.deletions > 0 && (
- -{file.deletions}
- )}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Inline Comment Form
-// ---------------------------------------------------------------------------
-
-function InlineCommentForm({
- isMultiLine,
- startLine,
- endLine,
- onSubmit,
- onCancel,
-}: {
- isMultiLine?: boolean;
- startLine?: number;
- endLine?: number;
- onSubmit: (body: string) => void;
- onCancel: () => void;
-}) {
- const [body, setBody] = useState("");
- const textareaRef = useRef(null);
-
- useEffect(() => {
- textareaRef.current?.focus();
- }, []);
-
- return (
-
- {isMultiLine && startLine != null && endLine != null && (
-
- Commenting on lines {startLine}–{endLine}
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Comment Bubbles
-// ---------------------------------------------------------------------------
-
-function ReviewCommentBubble({ comment }: { comment: PullReviewComment }) {
- return (
-
-
- {comment.author && (
- <>
-

-
{comment.author.login}
- >
- )}
-
- {formatRelativeTime(comment.createdAt)}
-
-
-
- {comment.body}
-
-
- );
-}
-
-function PendingCommentBubble({ comment }: { comment: PendingComment }) {
- const isMultiLine =
- comment.startLine != null && comment.startLine !== comment.line;
-
- return (
-
-
-
-
- Pending
- {isMultiLine ? ` (lines ${comment.startLine}–${comment.line})` : ""}
-
-
-
- {comment.body}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Review Submit Popover
-// ---------------------------------------------------------------------------
-
-function ReviewSubmitPopover({
- pendingCount,
- isSubmitting,
- onSubmit,
-}: {
- pendingCount: number;
- isSubmitting: boolean;
- onSubmit: (body: string, event: ReviewEvent) => void;
-}) {
- const [body, setBody] = useState("");
- const [event, setEvent] = useState("COMMENT");
- const [isOpen, setIsOpen] = useState(false);
-
- const handleSubmit = () => {
- onSubmit(body, event);
- setBody("");
- setIsOpen(false);
- };
-
- const reviewOptions: Array<{
- value: ReviewEvent;
- label: string;
- description: string;
- icon: typeof CommentIcon;
- color: string;
- }> = [
- {
- value: "COMMENT",
- label: "Comment",
- description: "Submit general feedback without explicit approval.",
- icon: CommentIcon,
- color: "text-foreground",
- },
- {
- value: "APPROVE",
- label: "Approve",
- description: "Submit feedback and approve merging these changes.",
- icon: TickIcon,
- color: "text-green-500",
- },
- {
- value: "REQUEST_CHANGES",
- label: "Request changes",
- description: "Submit feedback suggesting changes.",
- icon: GitBranchIcon,
- color: "text-red-500",
- },
- ];
-
- return (
-
-
-
-
-
-
-
-
-
Finish your review
-
-
-
-
-
-
-
- {reviewOptions.map((option) => {
- const Icon = option.icon;
- return (
-
- );
- })}
-
-
-
-
-
-
-
-
-
- );
-}