diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index c00bb1e0c..7b8cabb07 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -146,7 +146,11 @@ function normalizeRepositoryCloneUrls( function decodeGitHubJson( raw: string, schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + operation: + | "listOpenPullRequests" + | "getPullRequest" + | "getRepositoryCloneUrls" + | "listAllPullRequests", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -203,6 +207,48 @@ const makeGitHubCli = Effect.sync(() => { ), Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), ), + listAllPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--state", + input.state ?? "open", + "--limit", + String(input.limit ?? 50), + ...(input.label ? ["--label", input.label] : []), + "--json", + "number,title,url,baseRefName,headRefName,state,labels,updatedAt,author", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => { + if (raw.length === 0) return Effect.succeed([]); + return Effect.try({ + try: () => { + const parsed = JSON.parse(raw) as Array; + return parsed.map((pr: any) => ({ + number: pr.number, + title: pr.title, + url: pr.url, + baseRefName: pr.baseRefName, + headRefName: pr.headRefName, + state: pr.state === "MERGED" ? "merged" : pr.state === "CLOSED" ? "closed" : "open", + labels: (pr.labels ?? []).map((l: any) => ({ name: l.name, color: l.color ?? "" })), + updatedAt: pr.updatedAt ?? "", + author: pr.author?.login ?? "", + })); + }, + catch: (cause) => + new GitHubCliError({ + operation: "listAllPullRequests", + detail: "GitHub CLI returned invalid PR list JSON.", + cause, + }), + }); + }), + ), getPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 7fb784f75..46ba45cad 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -393,6 +393,35 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { (result) => JSON.parse(result.stdout) as ReadonlyArray, ), ), + listAllPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--state", + input.state ?? "open", + "--limit", + String(input.limit ?? 50), + "--json", + "number,title,url,baseRefName,headRefName,state,labels,updatedAt,author", + ], + }).pipe( + Effect.map((result) => { + const parsed = JSON.parse(result.stdout) as Array; + return parsed.map((pr: any) => ({ + number: pr.number, + title: pr.title, + url: pr.url, + baseRefName: pr.baseRefName, + headRefName: pr.headRefName, + state: pr.state === "MERGED" ? "merged" : pr.state === "CLOSED" ? "closed" : "open", + labels: (pr.labels ?? []).map((l: any) => ({ name: l.name, color: l.color ?? "" })), + updatedAt: pr.updatedAt ?? "", + author: pr.author?.login ?? "", + })); + }), + ), createPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 077920f98..a3d7a1162 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1273,11 +1273,37 @@ export const makeGitManager = Effect.gen(function* () { }, ); + const listPullRequests: GitManagerShape["listPullRequests"] = Effect.fnUntraced( + function* (input) { + const results = yield* gitHubCli.listAllPullRequests({ + cwd: input.cwd, + ...(input.state !== undefined ? { state: input.state } : {}), + ...(input.label !== undefined ? { label: input.label } : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }); + + return { + pullRequests: results.map((pr) => ({ + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + state: pr.state as "open" | "closed" | "merged", + labels: pr.labels, + updatedAt: pr.updatedAt, + author: pr.author, + })), + }; + }, + ); + return { status, resolvePullRequest, preparePullRequestThread, runStackedAction, + listPullRequests, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 9506b7d08..cd6ea77dc 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -51,6 +51,25 @@ export interface GitHubCliShape { readonly limit?: number; }) => Effect.Effect, GitHubCliError>; + /** + * List all pull requests for the repo, optionally filtered by state and label. + */ + readonly listAllPullRequests: (input: { + readonly cwd: string; + readonly state?: "open" | "closed" | "merged"; + readonly label?: string; + readonly limit?: number; + }) => Effect.Effect< + ReadonlyArray< + GitHubPullRequestSummary & { + labels: Array<{ name: string; color: string }>; + updatedAt: string; + author: string; + } + >, + GitHubCliError + >; + /** * Resolve a pull request by URL, number, or branch-ish identifier. */ diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index faa5b00d4..3ccd175c0 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -8,6 +8,8 @@ */ import { GitActionProgressEvent, + GitListPullRequestsInput, + GitListPullRequestsResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, @@ -55,6 +57,13 @@ export interface GitManagerShape { input: GitPreparePullRequestThreadInput, ) => Effect.Effect; + /** + * List pull requests for the repository, optionally filtered by state/label. + */ + readonly listPullRequests: ( + input: GitListPullRequestsInput, + ) => Effect.Effect; + /** * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 8e6267f5d..98236db54 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1757,6 +1757,7 @@ describe("WebSocket Server", () => { resolvePullRequest, preparePullRequestThread, runStackedAction, + listPullRequests: vi.fn(() => Effect.succeed({ pullRequests: [] })), }; server = await createTestServer({ cwd: "/test", gitManager }); @@ -1796,6 +1797,7 @@ describe("WebSocket Server", () => { resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), runStackedAction: vi.fn(() => Effect.void as any), + listPullRequests: vi.fn(() => Effect.succeed({ pullRequests: [] })), }; server = await createTestServer({ cwd: "/test", gitManager }); @@ -1844,6 +1846,7 @@ describe("WebSocket Server", () => { resolvePullRequest: vi.fn(() => Effect.void as any), preparePullRequestThread: vi.fn(() => Effect.void as any), runStackedAction, + listPullRequests: vi.fn(() => Effect.succeed({ pullRequests: [] })), }; server = await createTestServer({ cwd: "/test", gitManager }); @@ -1906,6 +1909,7 @@ describe("WebSocket Server", () => { resolvePullRequest: vi.fn(() => Effect.void as any), preparePullRequestThread: vi.fn(() => Effect.void as any), runStackedAction, + listPullRequests: vi.fn(() => Effect.succeed({ pullRequests: [] })), }; server = await createTestServer({ cwd: "/test", gitManager }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 5815f4071..fed048044 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -918,6 +918,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* gitManager.preparePullRequestThread(body); } + case WS_METHODS.gitListPullRequests: { + const body = stripRequestTag(request.body); + return yield* gitManager.listPullRequests(body); + } + case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); return yield* git.listBranches(body); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 474e794f6..9df285627 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -11,6 +11,7 @@ export const gitQueryKeys = { all: ["git"] as const, status: (cwd: string | null) => ["git", "status", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, + pullRequests: (cwd: string | null) => ["git", "pull-requests", cwd] as const, }; export const gitMutationKeys = { @@ -78,6 +79,37 @@ export function gitResolvePullRequestQueryOptions(input: { }); } +export function gitListPullRequestsQueryOptions(input: { + cwd: string | null; + state?: "open" | "closed" | "merged"; + label?: string; +}) { + return queryOptions({ + queryKey: [ + "git", + "pull-requests", + input.cwd, + input.state ?? "open", + input.label ?? "", + ] as const, + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd) throw new Error("Pull request listing is unavailable."); + return api.git.listPullRequests({ + cwd: input.cwd, + ...(input.state ? { state: input.state } : {}), + ...(input.label ? { label: input.label } : {}), + limit: 100, + }); + }, + enabled: input.cwd !== null, + staleTime: 15_000, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: 30_000, + }); +} + export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { return mutationOptions({ mutationKey: gitMutationKeys.init(input.cwd), diff --git a/apps/web/src/routes/_chat.pr-review.tsx b/apps/web/src/routes/_chat.pr-review.tsx index 5d5fc2c4f..c82172b56 100644 --- a/apps/web/src/routes/_chat.pr-review.tsx +++ b/apps/web/src/routes/_chat.pr-review.tsx @@ -1,4 +1,4 @@ -import type { GitResolvedPullRequest } from "@okcode/contracts"; +import type { GitResolvedPullRequest, GitResolvedPullRequestWithLabels } from "@okcode/contracts"; import { createFileRoute } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -8,42 +8,61 @@ import { CircleDotIcon, ExternalLinkIcon, FileCodeIcon, + FilterIcon, GitBranchIcon, GitMergeIcon, GitPullRequestIcon, + GridIcon, + KanbanIcon, + LayoutListIcon, MessageSquareIcon, + RowsIcon, SearchIcon, + TableIcon, + UserIcon, XCircleIcon, + XIcon, } from "lucide-react"; import { type ReactNode, useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { Badge } from "~/components/ui/badge"; 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 { ToggleGroup, Toggle as ToggleGroupItem } from "~/components/ui/toggle-group"; import { isElectron } from "~/env"; -import { gitResolvePullRequestQueryOptions } from "~/lib/gitReactQuery"; +import { + gitListPullRequestsQueryOptions, + gitResolvePullRequestQueryOptions, +} from "~/lib/gitReactQuery"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { useStore } from "~/store"; +// ── Types ──────────────────────────────────────────────────────────── + +type ViewMode = "table" | "list" | "kanban"; +type ListSubMode = "grid" | "rows"; + // ── Helpers ────────────────────────────────────────────────────────── function useFirstProjectCwd(): string | null { return useStore((store) => store.projects[0]?.cwd ?? null); } -function prStateIcon(state: string) { +function prStateIcon(state: string, className?: string) { + const cls = className ?? "size-4"; switch (state) { case "open": - return ; + return ; case "merged": - return ; + return ; case "closed": - return ; + return ; default: - return ; + return ; } } @@ -76,6 +95,35 @@ function prStateTone(state: string) { } } +function formatRelativeTime(dateString: string): string { + if (!dateString) return ""; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + const diffMonths = Math.floor(diffDays / 30); + return `${diffMonths}mo ago`; +} + +function labelColor(hex: string): { bg: string; text: string } { + if (!hex || hex.length < 6) return { bg: "bg-muted/60", text: "text-muted-foreground" }; + // Parse hex color and determine light vs dark + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return { + bg: `bg-[#${hex}]/15 dark:bg-[#${hex}]/20`, + text: luminance > 0.5 ? `text-[#${hex}]` : `text-[#${hex}]`, + }; +} + // ── Section wrapper (conversation-style) ──────────────────────────── function ReviewSection({ children, className }: { children: ReactNode; className?: string }) { @@ -94,131 +142,585 @@ function SectionLabel({ children }: { children: ReactNode }) { ); } -// ── PR Input ──────────────────────────────────────────────────────── +// ── Label Badge ────────────────────────────────────────────────────── -function PRInput({ - onResolve, - isResolving, - error, +function LabelBadge({ + label, + onClick, + active, }: { - onResolve: (reference: string) => void; - isResolving: boolean; - error: string | null; + label: { name: string; color: string }; + onClick?: () => void; + active?: boolean; }) { - const inputRef = useRef(null); - const [value, setValue] = useState(""); + const colors = labelColor(label.color); + return ( + + ); +} - useEffect(() => { - inputRef.current?.focus(); - }, []); +// ── State Filter Badge ─────────────────────────────────────────────── - const handleSubmit = useCallback(() => { - const trimmed = value.trim(); - if (trimmed.length > 0) { - onResolve(trimmed); - } - }, [onResolve, value]); +function StateBadge({ + state, + count, + active, + onClick, +}: { + state: string; + count: number; + active: boolean; + onClick: () => void; +}) { + const tone = prStateTone(state); + return ( + + ); +} + +// ── View Mode Toolbar ──────────────────────────────────────────────── +function ViewModeToolbar({ + viewMode, + onViewModeChange, + listSubMode, + onListSubModeChange, +}: { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; + listSubMode: ListSubMode; + onListSubModeChange: (mode: ListSubMode) => void; +}) { return ( -
-
-
- - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSubmit(); - } - }} - /> -
-
+ ); +} + +// ── Label Filter Bar ───────────────────────────────────────────────── + +function LabelFilterBar({ + allLabels, + activeLabel, + onLabelChange, + searchQuery, + onSearchChange, +}: { + allLabels: Array<{ name: string; color: string }>; + activeLabel: string | null; + onLabelChange: (label: string | null) => void; + searchQuery: string; + onSearchChange: (query: string) => void; +}) { + return ( +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + /> + {searchQuery.length > 0 && ( + + )} +
+ + {/* Labels */} + {allLabels.length > 0 && ( +
+ + {activeLabel && ( + )} - + {allLabels.map((label) => ( + onLabelChange(activeLabel === label.name ? null : label.name)} + /> + ))} +
+ )} +
+ ); +} + +// ── Table View ─────────────────────────────────────────────────────── + +function PRTableView({ + pullRequests, + onSelect, +}: { + pullRequests: readonly GitResolvedPullRequestWithLabels[]; + onSelect: (pr: GitResolvedPullRequestWithLabels) => void; +}) { + return ( +
+
+ + + + + + + + + + + + + {pullRequests.map((pr) => { + const tone = prStateTone(pr.state); + return ( + onSelect(pr)} + className="border-t border-border first:border-t-0 transition-colors hover:bg-muted/20 cursor-pointer group" + > + + + + + + + + ); + })} + +
+ PR + + Title + + Labels + + Branch + + Author + + Updated +
+
+ {prStateIcon(pr.state, "size-3.5")} + + #{pr.number} + +
+
+ + {pr.title} + + +
+ {pr.labels.slice(0, 3).map((label) => ( + + ))} + {pr.labels.length > 3 && ( + + +{pr.labels.length - 3} + + )} +
+
+ + {pr.headBranch} + + +
+ + {pr.author} +
+
+ + {formatRelativeTime(pr.updatedAt)} + +
- {error ?

{error}

: null} + {pullRequests.length === 0 && ( +
+
+ +

No pull requests match your filters.

+
+
+ )}
); } -// ── PR Header ─────────────────────────────────────────────────────── +// ── List View: Rows ────────────────────────────────────────────────── -function PRHeader({ pr }: { pr: GitResolvedPullRequest }) { - const tone = prStateTone(pr.state); +function PRListRowsView({ + pullRequests, + onSelect, +}: { + pullRequests: readonly GitResolvedPullRequestWithLabels[]; + onSelect: (pr: GitResolvedPullRequestWithLabels) => void; +}) { + if (pullRequests.length === 0) { + return ( +
+
+ +

No pull requests match your filters.

+
+
+ ); + } return ( - -
- {/* State badge + number */} -
-
-
+
+ {pullRequests.map((pr) => { + const tone = prStateTone(pr.state); + return ( + + ); + })} +
+ ); +} + +// ── List View: Grid ────────────────────────────────────────────────── + +function PRListGridView({ + pullRequests, + onSelect, +}: { + pullRequests: readonly GitResolvedPullRequestWithLabels[]; + onSelect: (pr: GitResolvedPullRequestWithLabels) => void; +}) { + if (pullRequests.length === 0) { + return ( +
+
+ +

No pull requests match your filters.

+
+
+ ); + } + + return ( +
+ {pullRequests.map((pr) => { + const tone = prStateTone(pr.state); + return ( + + ); + })} +
+ ); +} + +// ── Kanban View ────────────────────────────────────────────────────── + +function KanbanColumn({ + title, + state, + pullRequests, + onSelect, +}: { + title: string; + state: string; + pullRequests: readonly GitResolvedPullRequestWithLabels[]; + onSelect: (pr: GitResolvedPullRequestWithLabels) => void; +}) { + const tone = prStateTone(state); + + return ( +
+ {/* Column header */} +
+ {prStateIcon(state, "size-3.5")} + {title} + + {pullRequests.length} + +
+ + {/* Column content */} +
+ {pullRequests.length === 0 ? ( +
+

No PRs

-
+ ) : ( + pullRequests.map((pr) => ( + + )) + )}
- +
+ ); +} + +function PRKanbanView({ + pullRequests, + onSelect, +}: { + pullRequests: readonly GitResolvedPullRequestWithLabels[]; + onSelect: (pr: GitResolvedPullRequestWithLabels) => void; +}) { + const grouped = useMemo(() => { + const open: GitResolvedPullRequestWithLabels[] = []; + const merged: GitResolvedPullRequestWithLabels[] = []; + const closed: GitResolvedPullRequestWithLabels[] = []; + + for (const pr of pullRequests) { + switch (pr.state) { + case "open": + open.push(pr); + break; + case "merged": + merged.push(pr); + break; + case "closed": + closed.push(pr); + break; + } + } + + return { open, merged, closed }; + }, [pullRequests]); + + return ( +
+ + + +
); } -// ── Review checklist ───────────────────────────────────────────────── +// ── PR Detail (single PR review) ───────────────────────────────────── interface ChecklistItem { id: string; @@ -355,75 +857,6 @@ function ReviewChecklist() { ); } -// ── 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([]); @@ -483,7 +916,88 @@ function ReviewNotes() { ); } -// ── Summary card ───────────────────────────────────────────────────── +function QuickActions({ pr }: { pr: GitResolvedPullRequest }) { + return ( + + Actions +
+ {pr.url ? ( + + ) : null} + + +
+
+ ); +} + +function PRHeader({ pr }: { pr: GitResolvedPullRequest }) { + const tone = prStateTone(pr.state); + + return ( + +
+
+
+
+ + {prStateIcon(pr.state)} + {pr.state} + + #{pr.number} +
+

+ {pr.title} +

+
+
+ +
+ + + {pr.headBranch} + + + + {pr.baseBranch} + +
+ + {pr.url ? ( + + + View on GitHub + + ) : null} +
+
+ ); +} function PRSummaryCard({ pr }: { pr: GitResolvedPullRequest }) { const tone = prStateTone(pr.state); @@ -524,11 +1038,52 @@ function PRSummaryCard({ pr }: { pr: GitResolvedPullRequest }) { ); } -// ── Main view ──────────────────────────────────────────────────────── +function BranchContext({ pr }: { pr: GitResolvedPullRequest }) { + return ( + + Branch context +
+
+
+
+ +
+
+

Source branch

+ {pr.headBranch} +
+
+ +
+ +
+ +
+
+ +
+
+

Target branch

+ {pr.baseBranch} +
+
+
+
+
+ ); +} -function PRReviewContent({ pr }: { pr: GitResolvedPullRequest }) { +function PRReviewContent({ pr, onBack }: { pr: GitResolvedPullRequest; onBack: () => void }) { return (
+ @@ -542,105 +1097,146 @@ function PRReviewContent({ pr }: { pr: GitResolvedPullRequest }) { ); } -function PRReviewEmptyState({ cwd }: { cwd: string | null }) { - const [reference, setReference] = useState(""); - const queryClient = useQueryClient(); - const [debouncedReference] = useDebouncedValue(reference, { wait: 400 }); +// ── PR List (main dashboard) ───────────────────────────────────────── - const parsedReference = parsePullRequestReference(reference); - const parsedDebouncedReference = parsePullRequestReference(debouncedReference); +function PRListDashboard({ cwd }: { cwd: string }) { + const [viewMode, setViewMode] = useState("table"); + const [listSubMode, setListSubMode] = useState("rows"); + const [searchQuery, setSearchQuery] = useState(""); + const [activeLabel, setActiveLabel] = useState(null); + const [selectedPr, setSelectedPr] = useState(null); - const resolveQuery = useQuery( - gitResolvePullRequestQueryOptions({ + const listQuery = useQuery( + gitListPullRequestsQueryOptions({ cwd, - reference: parsedDebouncedReference, + state: "open", }), ); - 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 ; + const pullRequests = listQuery.data?.pullRequests ?? []; + + // Extract unique labels + const allLabels = useMemo(() => { + const labelMap = new Map(); + for (const pr of pullRequests) { + for (const label of pr.labels) { + if (!labelMap.has(label.name)) { + labelMap.set(label.name, label); + } + } + } + return Array.from(labelMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + }, [pullRequests]); + + // Filter PRs + const filteredPRs = useMemo(() => { + let filtered = pullRequests; + + if (activeLabel) { + filtered = filtered.filter((pr) => pr.labels.some((l) => l.name === activeLabel)); + } + + if (searchQuery.trim().length > 0) { + const q = searchQuery.trim().toLowerCase(); + filtered = filtered.filter( + (pr) => + pr.title.toLowerCase().includes(q) || + pr.headBranch.toLowerCase().includes(q) || + pr.author.toLowerCase().includes(q) || + `#${pr.number}`.includes(q) || + pr.labels.some((l) => l.name.toLowerCase().includes(q)), + ); + } + + return filtered; + }, [pullRequests, activeLabel, searchQuery]); + + // If a PR is selected, show the detail view + if (selectedPr) { + return setSelectedPr(null)} />; } 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. -

+
+ {/* Title + view controls */} +
+
+

Pull Requests

+

+ {listQuery.isLoading + ? "Loading pull requests..." + : `${filteredPRs.length} of ${pullRequests.length} open pull request${pullRequests.length === 1 ? "" : "s"}`} +

+
+ +
- {/* 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) => ( - - ))} + {/* Loading state */} + {listQuery.isLoading && ( +
+
+ + Loading pull requests... +
-
+ )} + + {/* Error state */} + {listQuery.isError && !listQuery.isLoading && ( +
+

+ {listQuery.error instanceof Error + ? listQuery.error.message + : "Failed to load pull requests."} +

+ +
+ )} + + {/* Loaded state */} + {!listQuery.isLoading && !listQuery.isError && ( + <> + {/* Filter bar */} + + + {/* View content */} + {viewMode === "table" && ( + + )} + + {viewMode === "list" && listSubMode === "rows" && ( + + )} + + {viewMode === "list" && listSubMode === "grid" && ( + + )} + + {viewMode === "kanban" && ( + + )} + + )}
); } +// ── Route view ─────────────────────────────────────────────────────── + function PRReviewRouteView() { const cwd = useFirstProjectCwd(); @@ -671,9 +1267,9 @@ function PRReviewRouteView() { {/* Content */}
-
+
{cwd ? ( - + ) : (
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index d4bbbd0e0..8adba474d 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -165,6 +165,7 @@ export function createWsNativeApi(): NativeApi { checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), init: (input) => transport.request(WS_METHODS.gitInit, input), resolvePullRequest: (input) => transport.request(WS_METHODS.gitResolvePullRequest, input), + listPullRequests: (input) => transport.request(WS_METHODS.gitListPullRequests, input), preparePullRequestThread: (input) => transport.request(WS_METHODS.gitPreparePullRequestThread, input), onActionProgress: (callback) => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 6ac80390d..53fcef295 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -59,6 +59,25 @@ const GitResolvedPullRequest = Schema.Struct({ }); export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; +const GitPullRequestLabel = Schema.Struct({ + name: TrimmedNonEmptyStringSchema, + color: Schema.String, +}); +export type GitPullRequestLabel = typeof GitPullRequestLabel.Type; + +const GitResolvedPullRequestWithLabels = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyStringSchema, + url: Schema.String, + baseBranch: TrimmedNonEmptyStringSchema, + headBranch: TrimmedNonEmptyStringSchema, + state: GitPullRequestState, + labels: Schema.Array(GitPullRequestLabel), + updatedAt: Schema.String, + author: Schema.String, +}); +export type GitResolvedPullRequestWithLabels = typeof GitResolvedPullRequestWithLabels.Type; + // RPC Inputs export const GitStatusInput = Schema.Struct({ @@ -105,6 +124,14 @@ export const GitPullRequestRefInput = Schema.Struct({ }); export type GitPullRequestRefInput = typeof GitPullRequestRefInput.Type; +export const GitListPullRequestsInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + state: Schema.optional(GitPullRequestState), + label: Schema.optional(TrimmedNonEmptyStringSchema), + limit: Schema.optional(PositiveInt), +}); +export type GitListPullRequestsInput = typeof GitListPullRequestsInput.Type; + export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, @@ -187,6 +214,11 @@ export const GitResolvePullRequestResult = Schema.Struct({ }); export type GitResolvePullRequestResult = typeof GitResolvePullRequestResult.Type; +export const GitListPullRequestsResult = Schema.Struct({ + pullRequests: Schema.Array(GitResolvedPullRequestWithLabels), +}); +export type GitListPullRequestsResult = typeof GitListPullRequestsResult.Type; + export const GitPreparePullRequestThreadResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, branch: TrimmedNonEmptyStringSchema, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 32d299890..1360c84a4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -10,6 +10,8 @@ import type { GitInitInput, GitListBranchesInput, GitListBranchesResult, + GitListPullRequestsInput, + GitListPullRequestsResult, GitPullInput, GitPullResult, GitRemoveWorktreeInput, @@ -199,6 +201,7 @@ export interface NativeApi { checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; + listPullRequests: (input: GitListPullRequestsInput) => Promise; preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 0f176e3e3..73909260c 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -19,6 +19,7 @@ import { GitCreateWorktreeInput, GitInitInput, GitListBranchesInput, + GitListPullRequestsInput, GitPullInput, GitPullRequestRefInput, GitRemoveWorktreeInput, @@ -71,6 +72,7 @@ export const WS_METHODS = { gitInit: "git.init", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", + gitListPullRequests: "git.listPullRequests", // Terminal methods terminalOpen: "terminal.open", @@ -139,6 +141,7 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitInit, GitInitInput), tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), + tagRequestBody(WS_METHODS.gitListPullRequests, GitListPullRequestsInput), // Terminal methods tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),