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 ?? "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",
+ },
+};