From 37fae6f44a556182f4f9a8965d57f24ea2c000a9 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 27 Mar 2026 16:50:50 -0500 Subject: [PATCH 1/3] Add Chat PR Review Route and Component --- apps/web/src/routeTree.gen.ts | 30 +- apps/web/src/routes/_chat.pr-review.tsx | 721 ++++++++++++++++++++++++ 2 files changed, 748 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/_chat.pr-review.tsx 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..08f6f6363 --- /dev/null +++ b/apps/web/src/routes/_chat.pr-review.tsx @@ -0,0 +1,721 @@ +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 ? ( + + ) : 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} + +
+