diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index aa42dc76c..aa462e171 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -374,7 +374,8 @@ export default function Sidebar() {
(store) => store.clearProjectDraftThreadById,
);
const navigate = useNavigate();
- const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
+ const pathname = useLocation({ select: (loc) => loc.pathname });
+ const isOnSubPage = pathname === "/settings" || pathname === "/pr-review";
const { settings: appSettings, updateSettings } = useAppSettings();
const { resolvedTheme } = useTheme();
const { handleNewThread } = useHandleNewThread();
@@ -1886,8 +1887,8 @@ export default function Sidebar() {
-
- {isOnSettings ? (
+ {isOnSubPage ? (
+
Back
- ) : (
- void navigate({ to: "/settings" })}
- >
-
- Settings
-
- )}
-
+
+ ) : (
+ <>
+
+ void navigate({ to: "/pr-review" })}
+ >
+
+ PR Review
+
+
+
+ void navigate({ to: "/settings" })}
+ >
+
+ Settings
+
+
+ >
+ )}
>
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 880d5ef64..ae58b87d6 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as ChatRouteImport } from './routes/_chat'
import { Route as ChatIndexRouteImport } from './routes/_chat.index'
import { Route as ChatSettingsRouteImport } from './routes/_chat.settings'
+import { Route as ChatPrReviewRouteImport } from './routes/_chat.pr-review'
import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
const ChatRoute = ChatRouteImport.update({
@@ -28,6 +29,11 @@ const ChatSettingsRoute = ChatSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => ChatRoute,
} as any)
+const ChatPrReviewRoute = ChatPrReviewRouteImport.update({
+ id: '/pr-review',
+ path: '/pr-review',
+ getParentRoute: () => ChatRoute,
+} as any)
const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
id: '/$threadId',
path: '/$threadId',
@@ -37,10 +43,12 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof ChatIndexRoute
'/$threadId': typeof ChatThreadIdRoute
+ '/pr-review': typeof ChatPrReviewRoute
'/settings': typeof ChatSettingsRoute
}
export interface FileRoutesByTo {
'/$threadId': typeof ChatThreadIdRoute
+ '/pr-review': typeof ChatPrReviewRoute
'/settings': typeof ChatSettingsRoute
'/': typeof ChatIndexRoute
}
@@ -48,15 +56,22 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_chat': typeof ChatRouteWithChildren
'/_chat/$threadId': typeof ChatThreadIdRoute
+ '/_chat/pr-review': typeof ChatPrReviewRoute
'/_chat/settings': typeof ChatSettingsRoute
'/_chat/': typeof ChatIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/$threadId' | '/settings'
+ fullPaths: '/' | '/$threadId' | '/pr-review' | '/settings'
fileRoutesByTo: FileRoutesByTo
- to: '/$threadId' | '/settings' | '/'
- id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/'
+ to: '/$threadId' | '/pr-review' | '/settings' | '/'
+ id:
+ | '__root__'
+ | '/_chat'
+ | '/_chat/$threadId'
+ | '/_chat/pr-review'
+ | '/_chat/settings'
+ | '/_chat/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -86,6 +101,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChatSettingsRouteImport
parentRoute: typeof ChatRoute
}
+ '/_chat/pr-review': {
+ id: '/_chat/pr-review'
+ path: '/pr-review'
+ fullPath: '/pr-review'
+ preLoaderRoute: typeof ChatPrReviewRouteImport
+ parentRoute: typeof ChatRoute
+ }
'/_chat/$threadId': {
id: '/_chat/$threadId'
path: '/$threadId'
@@ -98,12 +120,14 @@ declare module '@tanstack/react-router' {
interface ChatRouteChildren {
ChatThreadIdRoute: typeof ChatThreadIdRoute
+ ChatPrReviewRoute: typeof ChatPrReviewRoute
ChatSettingsRoute: typeof ChatSettingsRoute
ChatIndexRoute: typeof ChatIndexRoute
}
const ChatRouteChildren: ChatRouteChildren = {
ChatThreadIdRoute: ChatThreadIdRoute,
+ ChatPrReviewRoute: ChatPrReviewRoute,
ChatSettingsRoute: ChatSettingsRoute,
ChatIndexRoute: ChatIndexRoute,
}
diff --git a/apps/web/src/routes/_chat.pr-review.tsx b/apps/web/src/routes/_chat.pr-review.tsx
new file mode 100644
index 000000000..5d5fc2c4f
--- /dev/null
+++ b/apps/web/src/routes/_chat.pr-review.tsx
@@ -0,0 +1,696 @@
+import type { GitResolvedPullRequest } from "@okcode/contracts";
+import { createFileRoute } from "@tanstack/react-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useDebouncedValue } from "@tanstack/react-pacer";
+import {
+ ArrowRightIcon,
+ CheckCircle2Icon,
+ CircleDotIcon,
+ ExternalLinkIcon,
+ FileCodeIcon,
+ GitBranchIcon,
+ GitMergeIcon,
+ GitPullRequestIcon,
+ MessageSquareIcon,
+ SearchIcon,
+ XCircleIcon,
+} from "lucide-react";
+import { type ReactNode, useCallback, useMemo, useRef, useState, useEffect } from "react";
+
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Separator } from "~/components/ui/separator";
+import { SidebarInset, SidebarTrigger } from "~/components/ui/sidebar";
+import { Spinner } from "~/components/ui/spinner";
+import { isElectron } from "~/env";
+import { gitResolvePullRequestQueryOptions } from "~/lib/gitReactQuery";
+import { cn } from "~/lib/utils";
+import { parsePullRequestReference } from "~/pullRequestReference";
+import { useStore } from "~/store";
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+function useFirstProjectCwd(): string | null {
+ return useStore((store) => store.projects[0]?.cwd ?? null);
+}
+
+function prStateIcon(state: string) {
+ switch (state) {
+ case "open":
+ return ;
+ case "merged":
+ return ;
+ case "closed":
+ return ;
+ default:
+ return ;
+ }
+}
+
+function prStateTone(state: string) {
+ switch (state) {
+ case "open":
+ return {
+ text: "text-emerald-600 dark:text-emerald-400",
+ bg: "bg-emerald-500/10 dark:bg-emerald-400/10",
+ border: "border-emerald-500/20 dark:border-emerald-400/20",
+ };
+ case "merged":
+ return {
+ text: "text-violet-600 dark:text-violet-400",
+ bg: "bg-violet-500/10 dark:bg-violet-400/10",
+ border: "border-violet-500/20 dark:border-violet-400/20",
+ };
+ case "closed":
+ return {
+ text: "text-zinc-500 dark:text-zinc-400",
+ bg: "bg-zinc-500/10 dark:bg-zinc-400/10",
+ border: "border-zinc-500/20 dark:border-zinc-400/20",
+ };
+ default:
+ return {
+ text: "text-muted-foreground",
+ bg: "bg-muted/50",
+ border: "border-border",
+ };
+ }
+}
+
+// ── Section wrapper (conversation-style) ────────────────────────────
+
+function ReviewSection({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SectionLabel({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ── PR Input ────────────────────────────────────────────────────────
+
+function PRInput({
+ onResolve,
+ isResolving,
+ error,
+}: {
+ onResolve: (reference: string) => void;
+ isResolving: boolean;
+ error: string | null;
+}) {
+ const inputRef = useRef(null);
+ const [value, setValue] = useState("");
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const handleSubmit = useCallback(() => {
+ const trimmed = value.trim();
+ if (trimmed.length > 0) {
+ onResolve(trimmed);
+ }
+ }, [onResolve, value]);
+
+ return (
+
+
+
+
+ setValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }}
+ />
+
+
+
+ {error ?
{error}
: null}
+
+ );
+}
+
+// ── PR Header ───────────────────────────────────────────────────────
+
+function PRHeader({ pr }: { pr: GitResolvedPullRequest }) {
+ const tone = prStateTone(pr.state);
+
+ return (
+
+
+ {/* State badge + number */}
+
+
+
+
+ {prStateIcon(pr.state)}
+ {pr.state}
+
+ #{pr.number}
+
+
+ {pr.title}
+
+
+
+
+ {/* Branch flow */}
+
+
+
+ {pr.headBranch}
+
+
+
+ {pr.baseBranch}
+
+
+
+ {/* Link */}
+ {pr.url ? (
+
+
+ View on GitHub
+
+ ) : null}
+
+
+ );
+}
+
+// ── Review checklist ─────────────────────────────────────────────────
+
+interface ChecklistItem {
+ id: string;
+ label: string;
+ description: string;
+}
+
+const REVIEW_CHECKLIST: ChecklistItem[] = [
+ {
+ id: "purpose",
+ label: "Purpose is clear",
+ description: "The PR title and description explain what this change does and why.",
+ },
+ {
+ id: "scope",
+ label: "Scope is reasonable",
+ description: "Changes are focused and don't mix unrelated concerns.",
+ },
+ {
+ id: "tests",
+ label: "Tests cover the change",
+ description: "New or modified behavior has corresponding test coverage.",
+ },
+ {
+ id: "breaking",
+ label: "No breaking changes",
+ description: "Public APIs, configs, and contracts remain backward compatible.",
+ },
+ {
+ id: "security",
+ label: "Security reviewed",
+ description: "No secrets, injections, or permission escalation concerns.",
+ },
+ {
+ id: "performance",
+ label: "Performance considered",
+ description: "No N+1 queries, unbounded loops, or memory leaks introduced.",
+ },
+];
+
+function ReviewChecklist() {
+ const [checked, setChecked] = useState>(new Set());
+
+ const toggle = useCallback((id: string) => {
+ setChecked((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ }, []);
+
+ const completedCount = checked.size;
+ const totalCount = REVIEW_CHECKLIST.length;
+ const allComplete = completedCount === totalCount;
+
+ return (
+
+ Review checklist
+
+ {/* Progress header */}
+
+
+ {allComplete ? "All checks passed" : "Review items"}
+
+
+ {completedCount}/{totalCount}
+
+
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Items */}
+
+ {REVIEW_CHECKLIST.map((item) => {
+ const isChecked = checked.has(item.id);
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+// ── Branch context ───────────────────────────────────────────────────
+
+function BranchContext({ pr }: { pr: GitResolvedPullRequest }) {
+ return (
+
+ Branch context
+
+
+
+
+
+
+
+
Source branch
+
{pr.headBranch}
+
+
+
+
+
+
+
+
+
+
+
Target branch
+
{pr.baseBranch}
+
+
+
+
+
+ );
+}
+
+// ── Quick actions ────────────────────────────────────────────────────
+
+function QuickActions({ pr }: { pr: GitResolvedPullRequest }) {
+ return (
+
+ Actions
+
+ {pr.url ? (
+ }
+ >
+
+ Open on GitHub
+
+ ) : null}
+
+
+
+
+ );
+}
+
+// ── Review notes ─────────────────────────────────────────────────────
+
+function ReviewNotes() {
+ const [notes, setNotes] = useState("");
+ const [savedNotes, setSavedNotes] = useState([]);
+
+ const handleAddNote = useCallback(() => {
+ const trimmed = notes.trim();
+ if (trimmed.length === 0) return;
+ setSavedNotes((prev) => [...prev, trimmed]);
+ setNotes("");
+ }, [notes]);
+
+ return (
+
+ Notes
+
+ {savedNotes.length > 0 ? (
+
+ {savedNotes.map((note, index) => (
+
+
{note}
+
Note {index + 1}
+
+ ))}
+
+ ) : null}
+
+
+
+
+ );
+}
+
+// ── Summary card ─────────────────────────────────────────────────────
+
+function PRSummaryCard({ pr }: { pr: GitResolvedPullRequest }) {
+ const tone = prStateTone(pr.state);
+
+ return (
+
+ At a glance
+
+
+
+ Status
+
+
+ {prStateIcon(pr.state)}
+ {pr.state}
+
+
+
+
+
+ Source
+
+
+ {pr.headBranch}
+
+
+
+
+
+ Target
+
+
+ {pr.baseBranch}
+
+
+
+
+ );
+}
+
+// ── Main view ────────────────────────────────────────────────────────
+
+function PRReviewContent({ pr }: { pr: GitResolvedPullRequest }) {
+ return (
+
+ );
+}
+
+function PRReviewEmptyState({ cwd }: { cwd: string | null }) {
+ const [reference, setReference] = useState("");
+ const queryClient = useQueryClient();
+ const [debouncedReference] = useDebouncedValue(reference, { wait: 400 });
+
+ const parsedReference = parsePullRequestReference(reference);
+ const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
+
+ const resolveQuery = useQuery(
+ gitResolvePullRequestQueryOptions({
+ cwd,
+ reference: parsedDebouncedReference,
+ }),
+ );
+
+ const cachedPr = useMemo(() => {
+ if (!cwd || !parsedReference) return null;
+ const cached = queryClient.getQueryData<{ pullRequest: GitResolvedPullRequest }>([
+ "git",
+ "pull-request",
+ cwd,
+ parsedReference,
+ ]);
+ return cached?.pullRequest ?? null;
+ }, [cwd, parsedReference, queryClient]);
+
+ const livePr =
+ parsedReference !== null && parsedReference === parsedDebouncedReference
+ ? (resolveQuery.data?.pullRequest ?? null)
+ : null;
+
+ const resolvedPr = livePr ?? cachedPr;
+
+ const isResolving =
+ parsedReference !== null &&
+ resolvedPr === null &&
+ (parsedReference !== parsedDebouncedReference ||
+ resolveQuery.isPending ||
+ resolveQuery.isFetching);
+
+ const error =
+ resolvedPr === null && resolveQuery.isError
+ ? resolveQuery.error instanceof Error
+ ? resolveQuery.error.message
+ : "Failed to resolve pull request."
+ : null;
+
+ if (resolvedPr) {
+ return ;
+ }
+
+ return (
+
+ {/* Hero */}
+
+
+ Review a pull request
+
+
+ Paste a GitHub PR URL or enter a number to get a structured breakdown. Walk through the
+ change, check off review items, and leave notes.
+
+
+
+ {/* Input */}
+
setReference(ref)} isResolving={isResolving} error={error} />
+
+ {/* Hint cards */}
+
+
Try with
+
+ {[
+ {
+ label: "PR URL",
+ example: "https://github.com/owner/repo/pull/42",
+ },
+ {
+ label: "PR number",
+ example: "#42 or 42",
+ },
+ ].map((hint) => (
+
+ ))}
+
+
+
+ );
+}
+
+function PRReviewRouteView() {
+ const cwd = useFirstProjectCwd();
+
+ return (
+
+
+ {/* Header */}
+ {!isElectron && (
+
+ )}
+
+ {isElectron && (
+
+ )}
+
+ {/* Content */}
+
+
+ {cwd ? (
+
+ ) : (
+
+
+
+
+ Open a project to review pull requests.
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+export const Route = createFileRoute("/_chat/pr-review")({
+ component: PRReviewRouteView,
+});