From 4b77adf4279276e4911326c102bc3805e0510d81 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 20:41:22 -0400 Subject: [PATCH] Add review comment threading, custom diff themes, and thread resolution - Create custom QuickHub light/dark themes for @pierre/diffs, replacing color override unsafeCSS hacks with proper theme registration - Add threaded comment containers with reply forms and inline reply CTA for both PR detail activity and review diff pages - Add comment more-menu dropdown (copy link, copy markdown, edit, hide, delete) with author-only destructive actions - Add resolve/unresolve thread support via GraphQL mutations with collapse-on-resolve behavior and toggle icon in file headers - Add icon+spinner loading pattern to reply and send CTAs --- .../components/details/comment-more-menu.tsx | 168 +++++ .../components/details/detail-activity.tsx | 7 +- .../issues/detail/issue-detail-activity.tsx | 19 +- .../issues/detail/issue-detail-page.tsx | 6 + .../pulls/detail/pull-detail-activity.tsx | 590 +++++++++++++++++- .../pulls/detail/pull-detail-page.tsx | 24 + .../pulls/review/review-diff-pane.tsx | 32 + .../pulls/review/review-file-diff-block.tsx | 257 +++++++- .../components/pulls/review/review-page.tsx | 45 +- apps/dashboard/src/lib/github.functions.ts | 370 ++++++++++- apps/dashboard/src/lib/github.query.ts | 16 + apps/dashboard/src/lib/github.types.ts | 18 + packages/ui/src/lib/diffs-themes.ts | 312 +++++++++ 13 files changed, 1837 insertions(+), 27 deletions(-) create mode 100644 apps/dashboard/src/components/details/comment-more-menu.tsx create mode 100644 packages/ui/src/lib/diffs-themes.ts diff --git a/apps/dashboard/src/components/details/comment-more-menu.tsx b/apps/dashboard/src/components/details/comment-more-menu.tsx new file mode 100644 index 0000000..2015a1d --- /dev/null +++ b/apps/dashboard/src/components/details/comment-more-menu.tsx @@ -0,0 +1,168 @@ +import { + CopyIcon, + Delete01Icon, + EditIcon, + LinkIcon, + MoreHorizontalIcon, + ViewIcon, +} from "@diffkit/icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@diffkit/ui/components/dropdown-menu"; +import { toast } from "@diffkit/ui/components/sonner"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + deleteComment, + deleteReviewComment, + minimizeComment, +} from "#/lib/github.functions"; +import { githubQueryKeys } from "#/lib/github.query"; + +type CommentMoreMenuProps = { + commentId: number; + body: string; + owner: string; + repo: string; + number: number; + commentType: "issue" | "review"; + isAuthor: boolean; + onStartEdit?: () => void; +}; + +export function CommentMoreMenu({ + commentId, + body, + owner, + repo, + number, + commentType, + isAuthor, + onStartEdit, +}: CommentMoreMenuProps) { + const queryClient = useQueryClient(); + const [isDeleting, setIsDeleting] = useState(false); + const [isHiding, setIsHiding] = useState(false); + + const commentUrl = `https://github.com/${owner}/${repo}/${commentType === "review" ? "pull" : "issues"}/${number}#${commentType === "review" ? "discussion_r" : "issuecomment-"}${commentId}`; + + const handleCopyLink = () => { + void navigator.clipboard.writeText(commentUrl); + toast.success("Link copied"); + }; + + const handleCopyMarkdown = () => { + void navigator.clipboard.writeText(body); + toast.success("Markdown copied"); + }; + + const handleHide = async () => { + setIsHiding(true); + try { + const result = await minimizeComment({ + data: { owner, repo, commentId, commentType }, + }); + if (result.ok) { + toast.success("Comment hidden"); + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } else { + toast.error(result.error); + } + } catch { + toast.error("Failed to hide comment"); + } finally { + setIsHiding(false); + } + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const deleteFn = + commentType === "review" ? deleteReviewComment : deleteComment; + const result = await deleteFn({ + data: { owner, repo, commentId }, + }); + if (result.ok) { + toast.success("Comment deleted"); + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } else { + toast.error(result.error); + } + } catch { + toast.error("Failed to delete comment"); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + + + + + Copy link + + + + Copy Markdown + + {isAuthor && ( + <> + + {onStartEdit && ( + + + Edit + + )} + void handleHide()} + disabled={isHiding} + > + {isHiding ? ( + + ) : ( + + )} + Hide + + void handleDelete()} + disabled={isDeleting} + variant="destructive" + > + {isDeleting ? ( + + ) : ( + + )} + Delete + + + )} + + + ); +} diff --git a/apps/dashboard/src/components/details/detail-activity.tsx b/apps/dashboard/src/components/details/detail-activity.tsx index b9d79b0..8b1547c 100644 --- a/apps/dashboard/src/components/details/detail-activity.tsx +++ b/apps/dashboard/src/components/details/detail-activity.tsx @@ -1,3 +1,4 @@ +import { CommentIcon } from "@diffkit/icons"; import { MarkdownEditor, type MentionCandidate, @@ -140,7 +141,11 @@ export function DetailCommentBox({ onClick={handleSend} className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity disabled:opacity-40" > - {isSending && } + {isSending ? ( + + ) : ( + + )} Send diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index 9fcd7c5..dce1197 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -11,6 +11,7 @@ import { Markdown } from "@diffkit/ui/components/markdown"; import { cn } from "@diffkit/ui/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useRef, useState } from "react"; +import { CommentMoreMenu } from "#/components/details/comment-more-menu"; import { DetailActivityHeader, DetailCommentBox, @@ -238,6 +239,7 @@ export function IssueDetailActivitySection({ issueNumber, scope, issueAuthor, + viewerLogin, }: { comments?: IssueComment[]; events?: TimelineEvent[]; @@ -250,6 +252,7 @@ export function IssueDetailActivitySection({ issueNumber: number; scope: GitHubQueryScope; issueAuthor: GitHubActor | null; + viewerLogin?: string; }) { const allItems: IssueTimelineItem[] = [ ...(comments ?? []).map((comment) => ({ @@ -340,7 +343,7 @@ export function IssueDetailActivitySection({
@@ -360,6 +363,20 @@ export function IssueDetailActivitySection({ {formatRelativeTime(comment.createdAt)} +
+ +
{comment.body} diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx index 994970b..517f658 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx @@ -10,6 +10,7 @@ import { import { githubIssuePageQueryOptions, githubQueryKeys, + githubViewerQueryOptions, } from "#/lib/github.query"; import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; import { useGitHubSignalStream } from "#/lib/use-github-signal-stream"; @@ -49,6 +50,10 @@ export function IssueDetailPage() { ...githubIssuePageQueryOptions(scope, input), enabled: hasMounted, }); + const viewerQuery = useQuery({ + ...githubViewerQueryOptions(scope), + enabled: hasMounted, + }); useGitHubSignalStream(webhookRefreshTargets); const issue = pageQuery.data?.detail; @@ -90,6 +95,7 @@ export function IssueDetailPage() { issueNumber={issueNumber} scope={scope} issueAuthor={issue.author} + viewerLogin={viewerQuery.data?.login} /> } diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index df6fe9b..8c8ce7b 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronUpIcon, CircleIcon, + CommentIcon, Delete01Icon, EditIcon, GitBranchIcon, @@ -29,12 +30,26 @@ import { DropdownMenuTrigger, } from "@diffkit/ui/components/dropdown-menu"; import { Markdown } from "@diffkit/ui/components/markdown"; +import { MarkdownEditor } from "@diffkit/ui/components/markdown-editor"; import { Skeleton } from "@diffkit/ui/components/skeleton"; import { toast } from "@diffkit/ui/components/sonner"; import { Spinner } from "@diffkit/ui/components/spinner"; +import { quickhubDark, quickhubLight } from "@diffkit/ui/lib/diffs-themes"; import { cn } from "@diffkit/ui/lib/utils"; +import type { DiffLineAnnotation, PatchDiffProps } from "@pierre/diffs/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import { + type ComponentType, + type LazyExoticComponent, + lazy, + Suspense, + useCallback, + useMemo, + useRef, + useState, +} from "react"; +import { CommentMoreMenu } from "#/components/details/comment-more-menu"; import { DetailActivityHeader, DetailCommentBox, @@ -47,12 +62,16 @@ import { getCommentPage, getTimelineEventPage, mergePullRequest, + replyToReviewComment, requestPullReviewers, + resolveReviewThread, + unresolveReviewThread, updatePullBranch, } from "#/lib/github.functions"; import { type GitHubQueryScope, githubPullStatusQueryOptions, + githubQueryKeys, } from "#/lib/github.query"; import type { CommentPagination, @@ -63,15 +82,40 @@ import type { PullCommit, PullDetail, PullPageData, + PullReviewComment, PullStatus, TimelineEvent, } from "#/lib/github.types"; import { checkPermissionWarning } from "#/lib/warning-store"; +// Lazy-load PatchDiff for review comment diff hunks +type ActivityPatchDiffProps = PatchDiffProps; + +const PatchDiff: LazyExoticComponent> = + lazy(() => + import.meta.env.SSR + ? Promise.resolve({ + default: (() => null) as ComponentType, + }) + : import("@pierre/diffs/react").then((mod) => ({ + default: mod.PatchDiff as ComponentType, + })), + ); + +let themesRegistered = false; +if (!import.meta.env.SSR && !themesRegistered) { + themesRegistered = true; + import("@pierre/diffs").then(({ registerCustomTheme }) => { + registerCustomTheme("quickhub-light", () => Promise.resolve(quickhubLight)); + registerCustomTheme("quickhub-dark", () => Promise.resolve(quickhubDark)); + }); +} + export function PullDetailActivitySection({ comments, commits, events, + reviewComments, commentPagination, eventPagination, pageQueryKey, @@ -82,10 +126,13 @@ export function PullDetailActivitySection({ pullNumber, scope, headRefDeleted, + viewerLogin, + threadInfoByCommentId, }: { comments?: PullComment[]; commits?: PullCommit[]; events?: TimelineEvent[]; + reviewComments?: PullReviewComment[]; commentPagination?: CommentPagination; eventPagination?: EventPagination; pageQueryKey: readonly unknown[]; @@ -96,6 +143,11 @@ export function PullDetailActivitySection({ pullNumber: number; scope: GitHubQueryScope; headRefDeleted: boolean; + viewerLogin?: string; + threadInfoByCommentId?: ReadonlyMap< + number, + { threadId: string; isResolved: boolean } + >; }) { return (
@@ -146,6 +198,7 @@ export function PullDetailActivitySection({ comments={comments ?? []} commits={commits ?? []} events={events ?? []} + reviewComments={reviewComments ?? []} pr={pr} commentPagination={commentPagination} eventPagination={eventPagination} @@ -153,6 +206,8 @@ export function PullDetailActivitySection({ owner={owner} repo={repo} pullNumber={pullNumber} + viewerLogin={viewerLogin} + threadInfoByCommentId={threadInfoByCommentId} /> {!pr.isMerged && pr.state !== "closed" && ( @@ -1330,6 +1385,7 @@ function ActivityTimeline({ comments, commits, events, + reviewComments, pr, commentPagination, eventPagination, @@ -1337,10 +1393,13 @@ function ActivityTimeline({ owner, repo, pullNumber, + viewerLogin, + threadInfoByCommentId, }: { comments: PullComment[]; commits: PullCommit[]; events: TimelineEvent[]; + reviewComments: PullReviewComment[]; pr: PullDetail; commentPagination?: CommentPagination; eventPagination?: EventPagination; @@ -1348,7 +1407,31 @@ function ActivityTimeline({ owner: string; repo: string; pullNumber: number; + viewerLogin?: string; + threadInfoByCommentId?: ReadonlyMap< + number, + { threadId: string; isResolved: boolean } + >; }) { + // Group review comments by their review ID for rendering under reviewed events + // Also build a replies map keyed by parent comment ID + const { reviewCommentsByReviewId, repliesByCommentId } = useMemo(() => { + const byReview = new Map(); + const replies = new Map(); + for (const comment of reviewComments) { + if (comment.inReplyToId != null) { + const existing = replies.get(comment.inReplyToId) ?? []; + existing.push(comment); + replies.set(comment.inReplyToId, existing); + continue; + } + if (comment.pullRequestReviewId == null) continue; + const existing = byReview.get(comment.pullRequestReviewId) ?? []; + existing.push(comment); + byReview.set(comment.pullRequestReviewId, existing); + } + return { reviewCommentsByReviewId: byReview, repliesByCommentId: replies }; + }, [reviewComments]); const allItems: TimelineItem[] = [ ...comments.map((comment) => ({ type: "comment" as const, @@ -1411,7 +1494,7 @@ function ActivityTimeline({
@@ -1431,6 +1514,20 @@ function ActivityTimeline({ {formatRelativeTime(comment.createdAt)} +
+ +
{comment.body} @@ -1533,6 +1630,17 @@ function ActivityTimeline({ isConsecutive={isConsecutiveEvent} isLastInRun={isLastInEventRun} headRefName={pr.headRefName} + reviewComments={ + event.event === "reviewed" + ? reviewCommentsByReviewId.get(event.id) + : undefined + } + repliesByCommentId={repliesByCommentId} + owner={owner} + repo={repo} + pullNumber={pullNumber} + viewerLogin={viewerLogin} + threadInfoByCommentId={threadInfoByCommentId} /> ); })(); @@ -1626,12 +1734,29 @@ function TimelineEventRow({ isConsecutive, isLastInRun, headRefName, + reviewComments, + repliesByCommentId, + owner, + repo, + pullNumber, + viewerLogin, + threadInfoByCommentId, }: { event: TimelineEvent; isFirst: boolean; isConsecutive: boolean; isLastInRun: boolean; headRefName: string; + reviewComments?: PullReviewComment[]; + repliesByCommentId?: ReadonlyMap; + owner?: string; + repo?: string; + pullNumber?: number; + viewerLogin?: string; + threadInfoByCommentId?: ReadonlyMap< + number, + { threadId: string; isResolved: boolean } + >; }) { const icon = getEventIcon(event); const description = getEventDescription(event, headRefName); @@ -1641,6 +1766,49 @@ function TimelineEventRow({ if (!description) return null; + const hasReviewBody = event.body?.trim(); + const hasReviewComments = reviewComments && reviewComments.length > 0; + + // Reviewed events with a body or inline comments get a richer layout + if (event.event === "reviewed" && (hasReviewBody || hasReviewComments)) { + return ( +
+
+ {icon} + + {description} + + {event.createdAt && ( + + {formatRelativeTime(event.createdAt)} + + )} +
+ {hasReviewBody && ( + + {event.body as string} + + )} + {hasReviewComments && ( +
+ {reviewComments.map((comment) => ( + + ))} +
+ )} +
+ ); + } + return (
l.startsWith("@@")); + if (headerIdx === -1 || commentLine == null) return diffHunk; + + const header = lines[headerIdx]; + const body = lines.slice(headerIdx + 1); + + const match = header.match( + /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/, + ); + if (!match) return diffHunk; + + const oldStart = Number(match[1]); + const newStart = Number(match[3]); + + // Walk the body to build a mapping of hunk-index → source line number + // so we can find where `startLine` appears. + const targetStart = startLine ?? commentLine; + let oldLine = oldStart; + let newLine = newStart; + let firstKeepIdx = body.length; // default: keep nothing extra + + for (let i = 0; i < body.length; i++) { + const l = body[i]; + const currentLine = side === "LEFT" ? oldLine : newLine; + + if (currentLine >= targetStart && firstKeepIdx === body.length) { + firstKeepIdx = i; + } + + if (l.startsWith("-")) { + oldLine++; + } else if (l.startsWith("+")) { + newLine++; + } else { + oldLine++; + newLine++; + } + } + + // Add context lines above the range start + const sliceStart = Math.max(0, firstKeepIdx - CONTEXT_LINES_ABOVE); + const trimmed = body.slice(sliceStart); + + if (sliceStart === 0) return diffHunk; + + // Count how many old/new lines were dropped to rewrite the header + let droppedOld = 0; + let droppedNew = 0; + for (const line of body.slice(0, sliceStart)) { + if (line.startsWith("-")) droppedOld++; + else if (line.startsWith("+")) droppedNew++; + else { + droppedOld++; + droppedNew++; + } + } + + const oldCount = Number(match[2] ?? 1); + const newCount = Number(match[4] ?? 1); + const newHeader = `@@ -${oldStart + droppedOld},${oldCount - droppedOld} +${newStart + droppedNew},${newCount - droppedNew} @@${match[5]}`; + return [newHeader, ...trimmed].join("\n"); +} + +function ReviewCommentBubble({ + comment, + owner, + repo, + pullNumber, + viewerLogin, +}: { + comment: PullReviewComment; + owner?: string; + repo?: string; + pullNumber?: number; + viewerLogin?: string; +}) { + return ( +
+
+ {comment.author ? ( + {comment.author.login} + ) : ( +
+ )} + + {comment.author?.login ?? "Unknown"} + + + {formatRelativeTime(comment.createdAt)} + + {owner && repo && pullNumber != null && ( +
+ +
+ )} +
+ {comment.body} +
+ ); +} + +function ReviewCommentBlock({ + comment, + replies, + owner, + repo, + pullNumber, + viewerLogin, + threadInfo, +}: { + comment: PullReviewComment; + replies?: PullReviewComment[]; + owner?: string; + repo?: string; + pullNumber?: number; + viewerLogin?: string; + threadInfo?: { threadId: string; isResolved: boolean }; +}) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const queryClient = useQueryClient(); + const [showReplyForm, setShowReplyForm] = useState(false); + const [replyBody, setReplyBody] = useState(""); + const [isSending, setIsSending] = useState(false); + const [isCollapsed, setIsCollapsed] = useState( + threadInfo?.isResolved ?? false, + ); + const [isResolving, setIsResolving] = useState(false); + + const diffOptions = useMemo( + () => ({ + diffStyle: "unified" as const, + theme: { + dark: "quickhub-dark" as const, + light: "quickhub-light" as const, + }, + lineDiffType: "none" as const, + hunkSeparators: "line-info" as const, + overflow: "scroll" as const, + disableFileHeader: true, + unsafeCSS: [ + `:host { color-scheme: ${isDark ? "dark" : "light"}; }`, + `:host { --diffs-font-family: 'Geist Mono Variable', 'SF Mono', ui-monospace, 'Cascadia Code', monospace; }`, + `[data-diff] { border: none; border-radius: 0; overflow: hidden; }`, + `[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)); }`, + `[data-annotation-content] { background-color: transparent; }`, + ].join("\n"), + }), + [isDark], + ); + + const patch = useMemo(() => { + const trimmed = trimDiffHunk( + comment.diffHunk, + comment.line, + comment.startLine, + comment.side, + ); + return `--- a/${comment.path}\n+++ b/${comment.path}\n${trimmed}`; + }, [ + comment.diffHunk, + comment.line, + comment.startLine, + comment.side, + comment.path, + ]); + + const lineAnnotations = useMemo(() => { + if (comment.line == null) return []; + return [ + { + side: + comment.side === "LEFT" + ? ("deletions" as const) + : ("additions" as const), + lineNumber: comment.line, + metadata: comment, + }, + ]; + }, [comment]); + + const handleReply = useCallback(async () => { + if (!replyBody.trim() || !owner || !repo || pullNumber == null) return; + setIsSending(true); + try { + const result = await replyToReviewComment({ + data: { + owner, + repo, + pullNumber, + commentId: comment.id, + body: replyBody.trim(), + }, + }); + if (result) { + setReplyBody(""); + setShowReplyForm(false); + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } else { + toast.error("Failed to send reply"); + } + } catch { + toast.error("Failed to send reply"); + } finally { + setIsSending(false); + } + }, [replyBody, owner, repo, pullNumber, comment.id, queryClient]); + + const handleResolve = useCallback(async () => { + if (!threadInfo || !owner || !repo) return; + setIsResolving(true); + try { + const fn = threadInfo.isResolved + ? unresolveReviewThread + : resolveReviewThread; + const result = await fn({ + data: { owner, repo, threadId: threadInfo.threadId }, + }); + if (result.ok) { + if (!threadInfo.isResolved) setIsCollapsed(true); + void queryClient.invalidateQueries({ queryKey: githubQueryKeys.all }); + } else { + toast.error(result.error); + } + } catch { + toast.error("Failed to update thread"); + } finally { + setIsResolving(false); + } + }, [threadInfo, owner, repo, queryClient]); + + const canReply = owner && repo && pullNumber != null; + + const renderAnnotation = useCallback( + (annotation: DiffLineAnnotation) => { + const data = annotation.metadata; + if (!data) return null; + return ( +
+ + {replies?.map((reply) => ( + + ))} +
+ {showReplyForm ? ( +
+ +
+ + +
+
+ ) : ( + <> + {canReply && ( + + )} + {threadInfo && ( + + )} + + )} +
+
+ ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + replies, + showReplyForm, + replyBody, + isSending, + canReply, + owner, + repo, + pullNumber, + viewerLogin, + threadInfo, + isResolving, + handleReply, + handleResolve, + ], + ); + + return ( +
+
+ {threadInfo?.isResolved && ( + + + Resolved + + )} + + {comment.path} + + +
+ {!isCollapsed && comment.diffHunk && ( + + + + )} +
+ ); +} + function getEventIcon(event: TimelineEvent) { switch (event.event) { case "labeled": diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx index da65cba..f185516 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx @@ -9,7 +9,9 @@ import { } from "#/components/details/detail-page"; import { githubPullPageQueryOptions, + githubPullReviewCommentsQueryOptions, githubQueryKeys, + githubReviewThreadStatusesQueryOptions, githubViewerQueryOptions, } from "#/lib/github.query"; import { githubRevalidationSignalKeys } from "#/lib/github-revalidation"; @@ -59,8 +61,27 @@ export function PullDetailPage() { ...githubViewerQueryOptions(scope), enabled: hasMounted, }); + const reviewCommentsQuery = useQuery({ + ...githubPullReviewCommentsQueryOptions(scope, input), + enabled: hasMounted && !!pageQuery.data, + }); + const threadStatusesQuery = useQuery({ + ...githubReviewThreadStatusesQueryOptions(scope, input), + enabled: hasMounted && !!pageQuery.data, + }); useGitHubSignalStream(webhookRefreshTargets); + const threadInfoByCommentId = useMemo(() => { + const map = new Map(); + for (const t of threadStatusesQuery.data ?? []) { + map.set(t.firstCommentId, { + threadId: t.threadId, + isResolved: t.isResolved, + }); + } + return map; + }, [threadStatusesQuery.data]); + const pr = pageQuery.data?.detail; const comments = pageQuery.data?.comments; const commits = pageQuery.data?.commits; @@ -111,6 +132,7 @@ export function PullDetailPage() { comments={comments} commits={commits} events={events} + reviewComments={reviewCommentsQuery.data} commentPagination={commentPagination} eventPagination={eventPagination} pageQueryKey={pageQueryKey} @@ -121,6 +143,8 @@ export function PullDetailPage() { pullNumber={pullNumber} scope={scope} headRefDeleted={headRefDeleted} + viewerLogin={viewer?.login} + threadInfoByCommentId={threadInfoByCommentId} /> } diff --git a/apps/dashboard/src/components/pulls/review/review-diff-pane.tsx b/apps/dashboard/src/components/pulls/review/review-diff-pane.tsx index 1c9a14d..1b5b6ca 100644 --- a/apps/dashboard/src/components/pulls/review/review-diff-pane.tsx +++ b/apps/dashboard/src/components/pulls/review/review-diff-pane.tsx @@ -34,6 +34,10 @@ type ReviewDiffPaneProps = { string, DiffLineAnnotation[] >; + repliesByCommentId: ReadonlyMap; + owner: string; + repo: string; + pullNumber: number; pendingCommentsByFile: ReadonlyMap; hasNextPage: boolean; isFetchingNextPage: boolean; @@ -46,6 +50,11 @@ type ReviewDiffPaneProps = { onAddComment: (comment: PendingComment) => void; onEditComment: (original: PendingComment, newBody: string) => void; mentionConfig?: MentionConfig; + viewerLogin?: string; + threadInfoByCommentId?: ReadonlyMap< + number, + { threadId: string; isResolved: boolean } + >; }; export const ReviewDiffPane = memo( @@ -55,6 +64,10 @@ export const ReviewDiffPane = memo( totalFileCount, diffStyle, annotationsByFile, + repliesByCommentId, + owner, + repo, + pullNumber, pendingCommentsByFile, hasNextPage, isFetchingNextPage, @@ -67,6 +80,8 @@ export const ReviewDiffPane = memo( onAddComment, onEditComment, mentionConfig, + viewerLogin, + threadInfoByCommentId, }, ref, ) { @@ -278,6 +293,17 @@ export const ReviewDiffPane = memo( () => new Set(), ); + // Seed the first visible files as near-viewport immediately. + // During client-side navigation the scroll container may not have its + // final dimensions when the IntersectionObserver first checks, causing + // all diffs to remain as empty placeholders until a hard refresh. + useEffect(() => { + if (visibleFiles.length === 0 || nearViewportFiles.size > 0) return; + setNearViewportFiles( + new Set(visibleFiles.slice(0, 4).map((f) => f.filename)), + ); + }, [visibleFiles, nearViewportFiles.size]); + useEffect(() => { const panel = diffPanelRef.current; if (!panel || visibleFiles.length === 0) return; @@ -338,6 +364,10 @@ export const ReviewDiffPane = memo( annotations={ annotationsByFile.get(file.filename) ?? EMPTY_ANNOTATIONS } + repliesByCommentId={repliesByCommentId} + owner={owner} + repo={repo} + pullNumber={pullNumber} pendingComments={ pendingCommentsByFile.get(file.filename) ?? EMPTY_PENDING_COMMENTS @@ -355,6 +385,8 @@ export const ReviewDiffPane = memo( onAddComment={onAddComment} onEditComment={onEditComment} mentionConfig={mentionConfig} + viewerLogin={viewerLogin} + threadInfoByCommentId={threadInfoByCommentId} /> ))} 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 index c85c731..d3b5cfa 100644 --- a/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx +++ b/apps/dashboard/src/components/pulls/review/review-file-diff-block.tsx @@ -4,10 +4,13 @@ import { MarkdownEditor, type MentionConfig, } from "@diffkit/ui/components/markdown-editor"; -import { vercelDark, vercelLight } from "@diffkit/ui/lib/shiki-themes"; +import { toast } from "@diffkit/ui/components/sonner"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { quickhubDark, quickhubLight } from "@diffkit/ui/lib/diffs-themes"; import { cn } from "@diffkit/ui/lib/utils"; import type { SelectedLineRange } from "@pierre/diffs"; import type { DiffLineAnnotation, PatchDiffProps } from "@pierre/diffs/react"; +import { useQueryClient } from "@tanstack/react-query"; import { useTheme } from "next-themes"; import { type ComponentType, @@ -19,7 +22,14 @@ import { useMemo, useState, } from "react"; +import { CommentMoreMenu } from "#/components/details/comment-more-menu"; import { formatRelativeTime } from "#/lib/format-relative-time"; +import { + replyToReviewComment, + resolveReviewThread, + unresolveReviewThread, +} from "#/lib/github.functions"; +import { githubQueryKeys } from "#/lib/github.query"; import type { PullFile, PullReviewComment } from "#/lib/github.types"; import type { ActiveCommentForm, @@ -63,8 +73,8 @@ let themesRegistered = false; if (!import.meta.env.SSR && !themesRegistered) { themesRegistered = true; import("@pierre/diffs").then(({ registerCustomTheme }) => { - registerCustomTheme("vercel-light", () => Promise.resolve(vercelLight)); - registerCustomTheme("vercel-dark", () => Promise.resolve(vercelDark)); + registerCustomTheme("quickhub-light", () => Promise.resolve(quickhubLight)); + registerCustomTheme("quickhub-dark", () => Promise.resolve(quickhubDark)); }); } @@ -74,6 +84,10 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ diffStyle, isNearViewport, annotations, + repliesByCommentId, + owner, + repo, + pullNumber, pendingComments, activeCommentForm, selectedLines, @@ -82,12 +96,18 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ onAddComment, onEditComment, mentionConfig, + viewerLogin, + threadInfoByCommentId, }: { id: string; file: PullFile; diffStyle: "unified" | "split"; isNearViewport: boolean; annotations: DiffLineAnnotation[]; + repliesByCommentId: ReadonlyMap; + owner: string; + repo: string; + pullNumber: number; pendingComments: PendingComment[]; activeCommentForm: ActiveCommentForm | null; selectedLines: SelectedLineRange | null; @@ -96,6 +116,11 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ onAddComment: (comment: PendingComment) => void; onEditComment: (original: PendingComment, newBody: string) => void; mentionConfig?: MentionConfig; + viewerLogin?: string; + threadInfoByCommentId?: ReadonlyMap< + number, + { threadId: string; isResolved: boolean } + >; }) { const [isCollapsed, setIsCollapsed] = useState(false); const { resolvedTheme } = useTheme(); @@ -134,9 +159,6 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ return result; }, [annotations, pendingComments, activeCommentForm]); - const mutedFg = isDark - ? "oklch(0.705 0.015 286.067)" - : "oklch(0.552 0.016 285.938)"; const useWordDiff = file.changes <= LARGE_PATCH_CHANGE_THRESHOLD && (file.patch?.length ?? 0) <= LARGE_PATCH_CHAR_THRESHOLD; @@ -145,8 +167,8 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ () => ({ diffStyle, theme: { - dark: "vercel-dark" as const, - light: "vercel-light" as const, + dark: "quickhub-dark" as const, + light: "quickhub-light" as const, }, lineDiffType: useWordDiff ? ("word" as const) : ("none" as const), maxLineDiffLength: useWordDiff ? 1_000 : 200, @@ -157,19 +179,15 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ enableLineSelection: true, onGutterUtilityClick: handleGutterUtilityClick, unsafeCSS: [ - `:host { color-scheme: ${isDark ? "dark" : "light"}; ${isDark ? "" : "--diffs-light-bg: oklch(0.967 0.001 286.375);"} }`, + `:host { color-scheme: ${isDark ? "dark" : "light"}; }`, `: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)); }`, - `[data-diff] { border: 1px solid var(--border); border-top: 0; border-radius: 4px; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; overflow: hidden; }`, - 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)); }`, + `[data-annotation-content] { background-color: transparent; }`, + `[data-diff] { border: 1px solid var(--border); border-top: 0; border-radius: 0 0 4px 4px; overflow: hidden; }`, ].join("\n"), }), - [diffStyle, handleGutterUtilityClick, isDark, mutedFg, useWordDiff], + [diffStyle, handleGutterUtilityClick, isDark, useWordDiff], ); const patchString = useMemo(() => buildPatchString(file), [file]); @@ -261,8 +279,18 @@ export const ReviewFileDiffBlock = memo(function ReviewFileDiffBlock({ } if ("id" in data) { + const c = data as PullReviewComment; + const replies = repliesByCommentId.get(c.id); return ( - + ); } @@ -412,9 +440,21 @@ function InlineCommentForm({ ); } -function ReviewCommentBubble({ comment }: { comment: PullReviewComment }) { +function ReviewCommentBubble({ + comment, + owner, + repo, + pullNumber, + viewerLogin, +}: { + comment: PullReviewComment; + owner: string; + repo: string; + pullNumber: number; + viewerLogin?: string; +}) { return ( -
+
{comment.author ? ( {formatRelativeTime(comment.createdAt)} +
+ +
{comment.body}
); } +function ReviewCommentThread({ + comment, + replies, + owner, + repo, + pullNumber, + viewerLogin, + threadInfo, +}: { + comment: PullReviewComment; + replies?: PullReviewComment[]; + owner: string; + repo: string; + pullNumber: number; + viewerLogin?: string; + threadInfo?: { threadId: string; isResolved: boolean }; +}) { + const queryClient = useQueryClient(); + const [showReplyForm, setShowReplyForm] = useState(false); + const [replyBody, setReplyBody] = useState(""); + const [isSending, setIsSending] = useState(false); + const [isResolving, setIsResolving] = useState(false); + + const handleReply = async () => { + if (!replyBody.trim()) return; + setIsSending(true); + try { + const result = await replyToReviewComment({ + data: { + owner, + repo, + pullNumber, + commentId: comment.id, + body: replyBody.trim(), + }, + }); + if (result) { + setReplyBody(""); + setShowReplyForm(false); + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } else { + toast.error("Failed to send reply"); + } + } catch { + toast.error("Failed to send reply"); + } finally { + setIsSending(false); + } + }; + + const handleResolve = async () => { + if (!threadInfo) return; + setIsResolving(true); + try { + const fn = threadInfo.isResolved + ? unresolveReviewThread + : resolveReviewThread; + const result = await fn({ + data: { owner, repo, threadId: threadInfo.threadId }, + }); + if (result.ok) { + void queryClient.invalidateQueries({ queryKey: githubQueryKeys.all }); + } else { + toast.error(result.error); + } + } catch { + toast.error("Failed to update thread"); + } finally { + setIsResolving(false); + } + }; + + return ( +
+ {threadInfo?.isResolved && ( +
+ + Resolved +
+ )} + + {replies?.map((reply) => ( + + ))} +
+ {showReplyForm ? ( +
+ +
+ + +
+
+ ) : ( + <> + + {threadInfo && ( + + )} + + )} +
+
+ ); +} + function PendingCommentBubble({ comment, onEdit, diff --git a/apps/dashboard/src/components/pulls/review/review-page.tsx b/apps/dashboard/src/components/pulls/review/review-page.tsx index f65dcbe..e7b0e2e 100644 --- a/apps/dashboard/src/components/pulls/review/review-page.tsx +++ b/apps/dashboard/src/components/pulls/review/review-page.tsx @@ -24,7 +24,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; -import { getRouteApi, Link } from "@tanstack/react-router"; +import { getRouteApi, Link, useNavigate } from "@tanstack/react-router"; import { lazy, memo, @@ -44,6 +44,7 @@ import { githubPullReviewCommentsQueryOptions, githubQueryKeys, githubRepoCollaboratorsQueryOptions, + githubReviewThreadStatusesQueryOptions, githubViewerQueryOptions, } from "#/lib/github.query"; import type { @@ -96,6 +97,7 @@ function useIsDesktop() { export function ReviewPage() { const { user } = routeApi.useRouteContext(); const { owner, repo, pullId } = routeApi.useParams(); + const navigate = useNavigate(); const pullNumber = Number(pullId); const scope = useMemo(() => ({ userId: user.id }), [user.id]); const queryClient = useQueryClient(); @@ -174,6 +176,11 @@ export function ReviewPage() { enabled: hasDiffPayload, refetchOnWindowFocus: false, }); + const threadStatusesQuery = useQuery({ + ...githubReviewThreadStatusesQueryOptions(scope, input), + enabled: hasDiffPayload, + refetchOnWindowFocus: false, + }); useGitHubSignalStream(webhookRefreshTargets); const pr = pageQuery.data?.detail ?? null; @@ -293,7 +300,7 @@ export function ReviewPage() { const annotationsByFile = useMemo(() => { const map = new Map[]>(); for (const comment of reviewComments) { - if (comment.line == null) continue; + if (comment.line == null || comment.inReplyToId != null) continue; const existing = map.get(comment.path) ?? []; existing.push({ side: comment.side === "LEFT" ? "deletions" : "additions", @@ -305,6 +312,28 @@ export function ReviewPage() { return map; }, [reviewComments]); + const repliesByCommentId = useMemo(() => { + const map = new Map(); + for (const comment of reviewComments) { + if (comment.inReplyToId == null) continue; + const existing = map.get(comment.inReplyToId) ?? []; + existing.push(comment); + map.set(comment.inReplyToId, existing); + } + return map; + }, [reviewComments]); + + const threadInfoByCommentId = useMemo(() => { + const map = new Map(); + for (const t of threadStatusesQuery.data ?? []) { + map.set(t.firstCommentId, { + threadId: t.threadId, + isResolved: t.isResolved, + }); + } + return map; + }, [threadStatusesQuery.data]); + const pendingCommentsByFile = useMemo(() => { const map = new Map(); for (const comment of pendingComments) { @@ -420,6 +449,10 @@ export function ReviewPage() { void queryClient.invalidateQueries({ queryKey: githubQueryKeys.all, }); + void navigate({ + to: "/$owner/$repo/pull/$pullId", + params: { owner, repo, pullId }, + }); } else { toast.error("Failed to submit review"); } @@ -446,7 +479,7 @@ export function ReviewPage() { setIsSubmitting(false); } }, - [owner, pendingComments, pullNumber, queryClient, repo], + [navigate, owner, pendingComments, pullId, pullNumber, queryClient, repo], ); // Ref-stable onLoadMore — avoids busting ReviewDiffPane memo @@ -482,6 +515,10 @@ export function ReviewPage() { totalFileCount={sidebarFileCount} diffStyle={diffStyle} annotationsByFile={annotationsByFile} + repliesByCommentId={repliesByCommentId} + owner={owner} + repo={repo} + pullNumber={pullNumber} pendingCommentsByFile={pendingCommentsByFile} hasNextPage={filesQuery.hasNextPage} isFetchingNextPage={filesQuery.isFetchingNextPage} @@ -494,6 +531,8 @@ export function ReviewPage() { onAddComment={handleAddComment} onEditComment={handleEditComment} mentionConfig={mentionConfig} + viewerLogin={viewerQuery.data?.login} + threadInfoByCommentId={threadInfoByCommentId} /> ) : ( diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index d51b129..17a8b4e 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -31,6 +31,7 @@ import type { PullReviewComment, PullStatus, PullSummary, + ReplyToReviewCommentInput, RepoBranch, RepoCollaborator, RepoContributorsResult, @@ -39,6 +40,7 @@ import type { RepoTreeEntry, RequestedTeam, RequestReviewersInput, + ReviewThreadInfo, SetLabelsInput, SubmitReviewInput, TimelineEvent, @@ -2778,7 +2780,11 @@ function mapTimelineEvents(rawEvents: unknown[]): TimelineEvent[] { }) .map((e) => { const raw = e as Record; - const actor = raw.actor as Record | null | undefined; + // "reviewed" events use `user` instead of `actor` + const actor = (raw.actor ?? raw.user) as + | Record + | null + | undefined; const label = raw.label as Record | null | undefined; const assignee = raw.assignee as | Record @@ -5099,9 +5105,12 @@ async function getPullReviewCommentsResult( mapData: (comments) => comments.map((comment) => ({ id: comment.id, + nodeId: comment.node_id, + pullRequestReviewId: comment.pull_request_review_id ?? null, body: comment.body, path: comment.path, line: comment.line ?? null, + startLine: comment.start_line ?? null, side: (comment.side?.toUpperCase() as "LEFT" | "RIGHT") ?? "RIGHT", createdAt: comment.created_at, updatedAt: comment.updated_at, @@ -5130,6 +5139,141 @@ export const getPullReviewComments = createServerFn({ method: "GET" }) return getPullReviewCommentsResult(context, data); }); +// ── Get review thread resolution statuses ──────────────────────── + +type GraphQLReviewThread = { + id: string; + isResolved: boolean; + comments: { + nodes: Array<{ databaseId: number }>; + }; +}; + +type GraphQLReviewThreadsResponse = { + repository: { + pullRequest: { + reviewThreads: { + nodes: GraphQLReviewThread[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + }; + }; +}; + +export const getReviewThreadStatuses = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return []; + + try { + const threads: ReviewThreadInfo[] = []; + let cursor: string | null = null; + let hasNext = true; + + while (hasNext) { + const response: GraphQLReviewThreadsResponse = + await context.octokit.graphql( + `query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + nodes { + id + isResolved + comments(first: 1) { + nodes { databaseId } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + { + owner: data.owner, + repo: data.repo, + number: data.pullNumber, + cursor, + }, + ); + + const page = response.repository.pullRequest.reviewThreads; + for (const thread of page.nodes) { + const firstCommentId = thread.comments.nodes[0]?.databaseId; + if (firstCommentId != null) { + threads.push({ + threadId: thread.id, + isResolved: thread.isResolved, + firstCommentId, + }); + } + } + + hasNext = page.pageInfo.hasNextPage; + cursor = page.pageInfo.endCursor; + } + + return threads; + } catch { + return []; + } + }); + +// ── Resolve / unresolve review thread ──────────────────────────── + +export type ResolveThreadInput = { + owner: string; + repo: string; + threadId: string; +}; + +export const resolveReviewThread = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + await context.octokit.graphql( + `mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { isResolved } + } + }`, + { threadId: data.threadId }, + ); + return { ok: true }; + } catch (error) { + return toMutationError("resolve thread", error); + } + }); + +export const unresolveReviewThread = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + await context.octokit.graphql( + `mutation($threadId: ID!) { + unresolveReviewThread(input: { threadId: $threadId }) { + thread { isResolved } + } + }`, + { threadId: data.threadId }, + ); + return { ok: true }; + } catch (error) { + return toMutationError("unresolve thread", error); + } + }); + export const submitPullReview = createServerFn({ method: "POST" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { @@ -5197,9 +5341,64 @@ export const createReviewComment = createServerFn({ method: "POST" }) return { id: comment.id, + nodeId: comment.node_id, + pullRequestReviewId: comment.pull_request_review_id ?? null, + body: comment.body, + path: comment.path, + line: comment.line ?? null, + startLine: comment.start_line ?? null, + side: (comment.side?.toUpperCase() as "LEFT" | "RIGHT") ?? "RIGHT", + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user + ? { + login: comment.user.login, + avatarUrl: comment.user.avatar_url, + url: comment.user.html_url, + type: comment.user.type ?? "User", + } + : null, + inReplyToId: comment.in_reply_to_id ?? null, + diffHunk: comment.diff_hunk, + }; + } catch { + return null; + } + }); + +export const replyToReviewComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return null; + } + + try { + const response = + await context.octokit.rest.pulls.createReplyForReviewComment({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + comment_id: data.commentId, + body: data.body, + }); + + const comment = response.data; + await bustPullReviewCaches(context.session.user.id, { + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }); + + return { + id: comment.id, + nodeId: comment.node_id, + pullRequestReviewId: comment.pull_request_review_id ?? null, body: comment.body, path: comment.path, line: comment.line ?? null, + startLine: comment.start_line ?? null, side: (comment.side?.toUpperCase() as "LEFT" | "RIGHT") ?? "RIGHT", createdAt: comment.created_at, updatedAt: comment.updated_at, @@ -5278,6 +5477,175 @@ export const createComment = createServerFn({ method: "POST" }) } }); +// ── Edit issue/PR comment ───────────────────────────────────────── + +export type EditCommentInput = { + owner: string; + repo: string; + commentId: number; + body: string; +}; + +export const editComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + await context.octokit.rest.issues.updateComment({ + owner: data.owner, + repo: data.repo, + comment_id: data.commentId, + body: data.body, + }); + return { ok: true }; + } catch (error) { + return toMutationError("edit comment", error); + } + }); + +// ── Delete issue/PR comment ────────────────────────────────────── + +export type DeleteCommentInput = { + owner: string; + repo: string; + commentId: number; +}; + +export const deleteComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + await context.octokit.rest.issues.deleteComment({ + owner: data.owner, + repo: data.repo, + comment_id: data.commentId, + }); + return { ok: true }; + } catch (error) { + return toMutationError("delete comment", error); + } + }); + +// ── Edit review comment ────────────────────────────────────────── + +export type EditReviewCommentInput = { + owner: string; + repo: string; + commentId: number; + body: string; +}; + +export const editReviewComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + await context.octokit.rest.pulls.updateReviewComment({ + owner: data.owner, + repo: data.repo, + comment_id: data.commentId, + body: data.body, + }); + return { ok: true }; + } catch (error) { + return toMutationError("edit review comment", error); + } + }); + +// ── Delete review comment ──────────────────────────────────────── + +export type DeleteReviewCommentInput = { + owner: string; + repo: string; + commentId: number; +}; + +export const deleteReviewComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + await context.octokit.rest.pulls.deleteReviewComment({ + owner: data.owner, + repo: data.repo, + comment_id: data.commentId, + }); + return { ok: true }; + } catch (error) { + return toMutationError("delete review comment", error); + } + }); + +// ── Minimize (hide) comment ────────────────────────────────────── + +export type MinimizeCommentInput = { + owner: string; + repo: string; + commentId: number; + commentType: "issue" | "review"; +}; + +export const minimizeComment = createServerFn({ method: "POST" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubUserContextForRepository(data); + if (!context) { + return { ok: false, error: "Not authenticated" }; + } + + try { + // GitHub's minimize API requires the GraphQL node_id. + // Fetch the comment first to get the node_id, then minimize via GraphQL. + let nodeId: string; + if (data.commentType === "review") { + const { data: comment } = + await context.octokit.rest.pulls.getReviewComment({ + owner: data.owner, + repo: data.repo, + comment_id: data.commentId, + }); + nodeId = comment.node_id; + } else { + const { data: comment } = await context.octokit.rest.issues.getComment({ + owner: data.owner, + repo: data.repo, + comment_id: data.commentId, + }); + nodeId = comment.node_id; + } + + await context.octokit.graphql( + `mutation($id: ID!, $classifier: ReportedContentClassifiers!) { + minimizeComment(input: { subjectId: $id, classifier: $classifier }) { + minimizedComment { isMinimized } + } + }`, + { id: nodeId, classifier: "OFF_TOPIC" }, + ); + + return { ok: true }; + } catch (error) { + return toMutationError("minimize comment", error); + } + }); + export type CreateIssueInput = { owner: string; repo: string; diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index dc4fc83..82b3a36 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -28,6 +28,7 @@ import { getRepoLabels, getRepoOverview, getRepoTree, + getReviewThreadStatuses, getTimelineEventPage, getUserActivity, getUserContributions, @@ -150,6 +151,8 @@ export const githubQueryKeys = { ["github", scope.userId, "pulls", "files", input] as const, reviewComments: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => ["github", scope.userId, "pulls", "reviewComments", input] as const, + reviewThreads: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => + ["github", scope.userId, "pulls", "reviewThreads", input] as const, }, collaborators: ( scope: GitHubQueryScope, @@ -384,6 +387,19 @@ export function githubPullReviewCommentsQueryOptions( }); } +export function githubReviewThreadStatusesQueryOptions( + scope: GitHubQueryScope, + input: PullFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.reviewThreads(scope, input), + queryFn: () => getReviewThreadStatuses({ data: input }), + staleTime: githubCachePolicy.activity.staleTimeMs, + gcTime: githubCachePolicy.activity.gcTimeMs, + meta: tabPersistedMeta, + }); +} + export function githubRepoCollaboratorsQueryOptions( scope: GitHubQueryScope, input: { owner: string; repo: string }, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index c5a821f..a2bd9d5 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -272,9 +272,12 @@ export type PullFilesPage = { export type PullReviewComment = { id: number; + nodeId: string; + pullRequestReviewId: number | null; body: string; path: string; line: number | null; + startLine: number | null; side: "LEFT" | "RIGHT"; createdAt: string; updatedAt: string; @@ -283,6 +286,13 @@ export type PullReviewComment = { diffHunk: string; }; +export type ReviewThreadInfo = { + threadId: string; + isResolved: boolean; + /** The database ID of the first comment in this thread */ + firstCommentId: number; +}; + export type SubmitReviewInput = { owner: string; repo: string; @@ -349,6 +359,14 @@ export type CreateReviewCommentInput = { side: "LEFT" | "RIGHT"; }; +export type ReplyToReviewCommentInput = { + owner: string; + repo: string; + pullNumber: number; + commentId: number; + body: string; +}; + export type GitHubUserProfile = { id: number; login: string; diff --git a/packages/ui/src/lib/diffs-themes.ts b/packages/ui/src/lib/diffs-themes.ts new file mode 100644 index 0000000..9ea0246 --- /dev/null +++ b/packages/ui/src/lib/diffs-themes.ts @@ -0,0 +1,312 @@ +import type { ThemeRegistrationRaw } from "shiki"; + +// --------------------------------------------------------------------------- +// QuickHub custom themes for @pierre/diffs +// +// These map our design tokens to shiki's VS Code color model, which the diffs +// library reads to derive all its CSS variables (--diffs-bg, --diffs-fg, +// addition/deletion colors, etc.). +// +// The `colors` object drives the diff chrome; `tokenColors` drives syntax +// highlighting. We reuse the same Vercel-inspired token palette from +// shiki-themes.ts but pair it with our own editor/UI chrome colors so the +// diff viewer feels native to the app. +// --------------------------------------------------------------------------- + +// ---- Shared token palettes ------------------------------------------------ + +const lightTokenColors: ThemeRegistrationRaw["tokenColors"] = [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "#666666", fontStyle: "italic" }, + }, + { + scope: ["keyword", "storage", "storage.type", "storage.modifier"], + settings: { foreground: "#c41562" }, + }, + { + scope: ["string", "string.quoted", "string.template", "string.regexp"], + settings: { foreground: "#107d32" }, + }, + { + scope: [ + "constant", + "constant.numeric", + "constant.language", + "constant.character", + ], + settings: { foreground: "#005ff2" }, + }, + { + scope: ["entity.name.function", "support.function", "meta.function-call"], + settings: { foreground: "#7d00cc" }, + }, + { + scope: [ + "variable.parameter", + "meta.parameter", + "entity.name.variable.parameter", + ], + settings: { foreground: "#aa4d00" }, + }, + { + scope: [ + "variable.other.property", + "support.type.property-name", + "entity.name.tag", + "meta.object-literal.key", + ], + settings: { foreground: "#005ff2" }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + ], + settings: { foreground: "#005ff2" }, + }, + { + scope: ["punctuation", "meta.brace", "meta.bracket"], + settings: { foreground: "#171717" }, + }, + { + scope: ["variable", "variable.other"], + settings: { foreground: "#171717" }, + }, + { + scope: [ + "entity.other.attribute-name", + "entity.other.attribute-name.jsx", + "entity.other.attribute-name.tsx", + ], + settings: { foreground: "#aa4d00" }, + }, + { + scope: ["markup.deleted", "punctuation.definition.deleted"], + settings: { foreground: "#c41562" }, + }, + { + scope: ["markup.inserted", "punctuation.definition.inserted"], + settings: { foreground: "#107d32" }, + }, +]; + +const darkTokenColors: ThemeRegistrationRaw["tokenColors"] = [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "#a1a1a1", fontStyle: "italic" }, + }, + { + scope: ["keyword", "storage", "storage.type", "storage.modifier"], + settings: { foreground: "#ff4d8d" }, + }, + { + scope: ["string", "string.quoted", "string.template", "string.regexp"], + settings: { foreground: "#00ca50" }, + }, + { + scope: [ + "constant", + "constant.numeric", + "constant.language", + "constant.character", + ], + settings: { foreground: "#47a8ff" }, + }, + { + scope: ["entity.name.function", "support.function", "meta.function-call"], + settings: { foreground: "#c472fb" }, + }, + { + scope: [ + "variable.parameter", + "meta.parameter", + "entity.name.variable.parameter", + ], + settings: { foreground: "#ff9300" }, + }, + { + scope: [ + "variable.other.property", + "support.type.property-name", + "entity.name.tag", + "meta.object-literal.key", + ], + settings: { foreground: "#47a8ff" }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + ], + settings: { foreground: "#47a8ff" }, + }, + { + scope: ["punctuation", "meta.brace", "meta.bracket"], + settings: { foreground: "#ededed" }, + }, + { + scope: ["variable", "variable.other"], + settings: { foreground: "#ededed" }, + }, + { + scope: [ + "entity.other.attribute-name", + "entity.other.attribute-name.jsx", + "entity.other.attribute-name.tsx", + ], + settings: { foreground: "#ff9300" }, + }, + { + scope: ["markup.deleted", "punctuation.definition.deleted"], + settings: { foreground: "#ff4d8d" }, + }, + { + scope: ["markup.inserted", "punctuation.definition.inserted"], + settings: { foreground: "#00ca50" }, + }, +]; + +// ---- Theme definitions ---------------------------------------------------- + +// Light theme: oklch(1 0 0) = #ffffff, oklch(0.967 ...) ≈ #f4f4f5 +// surface-0 light = oklch(0.967 0.001 286.375) ≈ #f4f4f5 +// border light = oklch(0.92 0.004 286.32) ≈ #e8e8ea +// muted-fg light = oklch(0.552 0.016 285.938) ≈ #71717a + +export const quickhubLight: ThemeRegistrationRaw = { + name: "quickhub-light", + type: "light", + settings: lightTokenColors as ThemeRegistrationRaw["settings"], + tokenColors: lightTokenColors, + colors: { + // Editor chrome — matches our surface/background tokens + "editor.background": "#f4f4f5", // surface-0 light + "editor.foreground": "#171717", // foreground light + foreground: "#171717", + + // Selection — subtle blue-gray tint + "selection.background": "#e4e4e7", // surface-2 ish + "editor.selectionBackground": "#e4e4e780", + "editor.lineHighlightBackground": "#e4e4e74d", + + // Line numbers + "editorLineNumber.foreground": "#a1a1aa", // zinc-400 + "editorLineNumber.activeForeground": "#71717a", // muted-fg + + // Indent guides + "editorIndentGuide.background": "#e4e4e7", + "editorIndentGuide.activeBackground": "#d4d4d8", + + // Diff colors — green/red that feel at home + "diffEditor.insertedTextBackground": "#10b98120", + "diffEditor.deletedTextBackground": "#ef444420", + "gitDecoration.addedResourceForeground": "#10b981", + "gitDecoration.deletedResourceForeground": "#ef4444", + "gitDecoration.modifiedResourceForeground": "#3b82f6", + + // Sidebar/panel — surface-1 + "sideBar.background": "#ebebec", + "sideBar.foreground": "#71717a", + "sideBar.border": "#e8e8ea", + "panel.background": "#ebebec", + "panel.border": "#e8e8ea", + + // Input + "input.background": "#f4f4f5", + "input.border": "#e4e4e7", + "input.foreground": "#171717", + "input.placeholderForeground": "#a1a1aa", + + // Terminal ANSI + "terminal.ansiBlack": "#171717", + "terminal.ansiRed": "#ef4444", + "terminal.ansiGreen": "#10b981", + "terminal.ansiYellow": "#f59e0b", + "terminal.ansiBlue": "#3b82f6", + "terminal.ansiMagenta": "#a855f7", + "terminal.ansiCyan": "#06b6d4", + "terminal.ansiWhite": "#d4d4d8", + "terminal.ansiBrightBlack": "#52525b", + "terminal.ansiBrightRed": "#f87171", + "terminal.ansiBrightGreen": "#34d399", + "terminal.ansiBrightYellow": "#fbbf24", + "terminal.ansiBrightBlue": "#60a5fa", + "terminal.ansiBrightMagenta": "#c084fc", + "terminal.ansiBrightCyan": "#22d3ee", + "terminal.ansiBrightWhite": "#fafafa", + }, +}; + +// Dark theme: oklch(0.141 ...) = #1a1a1a-ish, oklch(0.21 ...) ≈ #2a2a2d +// surface-0 dark = oklch(0.21 0.006 286.033) ≈ #2a2a2d +// border dark = oklch(0.274 0.006 286.033) ≈ #3a3a3d +// muted-fg dark = oklch(0.705 0.015 286.067) ≈ #a1a1ab + +export const quickhubDark: ThemeRegistrationRaw = { + name: "quickhub-dark", + type: "dark", + settings: darkTokenColors as ThemeRegistrationRaw["settings"], + tokenColors: darkTokenColors, + colors: { + // Editor chrome — matches our surface/background tokens + "editor.background": "#1a1a1c", // background dark + "editor.foreground": "#fafafa", // foreground dark + foreground: "#fafafa", + + // Selection + "selection.background": "#3a3a3d", + "editor.selectionBackground": "#3a3a3d80", + "editor.lineHighlightBackground": "#3a3a3d4d", + + // Line numbers + "editorLineNumber.foreground": "#52525b", // zinc-600 + "editorLineNumber.activeForeground": "#a1a1ab", // muted-fg + + // Indent guides + "editorIndentGuide.background": "#2a2a2d", + "editorIndentGuide.activeBackground": "#3a3a3d", + + // Diff colors + "diffEditor.insertedTextBackground": "#10b98118", + "diffEditor.deletedTextBackground": "#ef444418", + "gitDecoration.addedResourceForeground": "#34d399", + "gitDecoration.deletedResourceForeground": "#f87171", + "gitDecoration.modifiedResourceForeground": "#60a5fa", + + // Sidebar/panel — surface-1 + "sideBar.background": "#27272a", + "sideBar.foreground": "#a1a1ab", + "sideBar.border": "#3a3a3d", + "panel.background": "#27272a", + "panel.border": "#3a3a3d", + + // Input + "input.background": "#27272a", + "input.border": "#3a3a3d", + "input.foreground": "#fafafa", + "input.placeholderForeground": "#71717a", + + // Terminal ANSI + "terminal.ansiBlack": "#27272a", + "terminal.ansiRed": "#f87171", + "terminal.ansiGreen": "#34d399", + "terminal.ansiYellow": "#fbbf24", + "terminal.ansiBlue": "#60a5fa", + "terminal.ansiMagenta": "#c084fc", + "terminal.ansiCyan": "#22d3ee", + "terminal.ansiWhite": "#d4d4d8", + "terminal.ansiBrightBlack": "#52525b", + "terminal.ansiBrightRed": "#fca5a5", + "terminal.ansiBrightGreen": "#6ee7b7", + "terminal.ansiBrightYellow": "#fde68a", + "terminal.ansiBrightBlue": "#93c5fd", + "terminal.ansiBrightMagenta": "#d8b4fe", + "terminal.ansiBrightCyan": "#67e8f9", + "terminal.ansiBrightWhite": "#fafafa", + }, +};