diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 87403fa3..6de581df 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,5 @@ import { ThreadId } from "@okcode/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { ArrowLeftIcon, @@ -22,6 +22,9 @@ import type { LucideIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { useStore } from "~/store"; import { useCommandPaletteStore } from "~/commandPaletteStore"; +import { usePrReviewCommands } from "~/components/pr-review/usePrReviewCommands"; +import { usePrReviewStore } from "~/prReviewStore"; +import { ensureNativeApi } from "~/nativeApi"; import { useHandleNewThread } from "~/hooks/useHandleNewThread"; import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates"; import { useTheme } from "~/hooks/useTheme"; @@ -147,6 +150,25 @@ function CommandsView() { const mruThreadIds = useCommandPaletteStore((s) => s.mruThreadIds); const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog); const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates(); + + // PR Review commands integration + const currentPath = useRouterState({ select: (s) => s.location.pathname }); + const isPrReviewRoute = currentPath.includes("/pr-review"); + const prReviewDashboard = usePrReviewStore((s) => s.selectedPrNumber); + const prReviewCommands = usePrReviewCommands({ + enabled: isPrReviewRoute, + onStartAgentReview: () => { + closePalette(); + const store = usePrReviewStore.getState(); + // Trigger is handled by the caller + document.dispatchEvent(new CustomEvent("command-palette:start-agent-review")); + }, + onOpenOnGitHub: () => { + closePalette(); + document.dispatchEvent(new CustomEvent("command-palette:open-on-github")); + }, + }); + const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), @@ -371,6 +393,11 @@ function CommandsView() { }, }); + // ── PR Review (conditionally visible) ── + for (const prCmd of prReviewCommands) { + cmds.push(prCmd); + } + return cmds.filter((cmd) => !cmd.hidden); }, [ projects, @@ -389,6 +416,7 @@ function CommandsView() { pushMruThread, openWorktreeCleanupDialog, hasWorktreeCleanupCandidates, + prReviewCommands, ]); // Filter commands by query diff --git a/apps/web/src/components/pr-review/PrActionRail.tsx b/apps/web/src/components/pr-review/PrActionRail.tsx new file mode 100644 index 00000000..777c502f --- /dev/null +++ b/apps/web/src/components/pr-review/PrActionRail.tsx @@ -0,0 +1,356 @@ +import type { + NativeApi, + PrAgentReviewResult, + PrConflictAnalysis, + PrReviewConfig, +} from "@okcode/contracts"; +import { useMemo, useState } from "react"; +import { + AlertTriangleIcon, + CheckCircle2Icon, + ChevronUpIcon, + MessageSquareIcon, + ShieldCheckIcon, + SparklesIcon, +} from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui/button"; +import { PrMentionComposer } from "./PrMentionComposer"; +import { requiredChecksState } from "./pr-review-utils"; + +// ── Local helpers ────────────────────────────────────────────────────── + +function formatReviewDecision(decision: string | null | undefined): string { + if (!decision) return "No decision yet"; + return decision.toLowerCase().replaceAll("_", " "); +} + +function reviewDecisionTone(decision: string | null | undefined): string { + switch (decision) { + case "APPROVED": + return "text-emerald-600 dark:text-emerald-400"; + case "CHANGES_REQUESTED": + case "REVIEW_REQUIRED": + return "text-amber-600 dark:text-amber-400"; + default: + return "text-muted-foreground"; + } +} + +function formatConflictStatus(status: string | null | undefined): string { + if (!status) return "Conflict status unknown"; + if (status === "clean") return "No merge conflicts"; + if (status === "conflicted") return "Merge conflicts"; + return status.replaceAll("_", " "); +} + +function formatReviewTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +// ── Types ────────────────────────────────────────────────────────────── + +type DashboardData = Awaited> | null | undefined; + +export function PrActionRail({ + projectCwd, + dashboard, + config, + conflicts, + agentResult, + reviewBody, + onReviewBodyChange, + onSubmitReview, + isSubmitting, + requestChangesVariant, +}: { + projectCwd: string; + dashboard: DashboardData; + config: PrReviewConfig | undefined; + conflicts: PrConflictAnalysis | undefined; + agentResult: PrAgentReviewResult | null | undefined; + reviewBody: string; + onReviewBodyChange: (body: string) => void; + onSubmitReview: (event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES") => void; + isSubmitting: boolean; + requestChangesVariant: "default" | "destructive-outline" | "outline"; +}) { + const [actionRailExpanded, setActionRailExpanded] = useState(false); + + // ── Derived data ─────────────────────────────────────────────────── + + const checksSummary = config + ? requiredChecksState(config, dashboard?.pullRequest.statusChecks ?? []) + : { failing: [] as string[], pending: [] as string[] }; + + const blockingWorkflowSteps = (dashboard?.workflowSteps ?? []).filter( + (step) => step.status === "blocked" || step.status === "failed", + ); + + const fileStats = useMemo(() => { + const files = dashboard?.files ?? []; + return files.reduce( + (totals, file) => ({ + changedFileCount: totals.changedFileCount + 1, + additions: totals.additions + file.additions, + deletions: totals.deletions + file.deletions, + }), + { changedFileCount: 0, additions: 0, deletions: 0 }, + ); + }, [dashboard?.files]); + + const approvalBlockers = useMemo( + () => [ + ...(conflicts?.status === "conflicted" ? ["Merge conflicts must be resolved"] : []), + ...checksSummary.failing.map((name) => `Failing check: ${name}`), + ...checksSummary.pending.map((name) => `Pending check: ${name}`), + ...blockingWorkflowSteps.map( + (step) => `Workflow blocked: ${step.detail ?? step.stepId}`, + ), + ], + [conflicts?.status, checksSummary.failing, checksSummary.pending, blockingWorkflowSteps], + ); + + const approveDisabled = + isSubmitting || + conflicts?.status === "conflicted" || + checksSummary.failing.length > 0 || + checksSummary.pending.length > 0; + + const recentReviews = dashboard?.pullRequest.recentReviews ?? []; + const displayedRecentReviews = recentReviews.slice(0, 3); + + const agentStatus = agentResult?.status ?? "idle"; + const agentIsRunning = agentStatus === "queued" || agentStatus === "running"; + + return ( +
+ {/* Collapsed bar */} +
+
+ Submit review + + {formatReviewDecision(dashboard?.pullRequest.reviewDecision)} + + + + {dashboard?.pullRequest.unresolvedThreadCount ?? 0} open + + {fileStats.changedFileCount} files + + + {formatConflictStatus(conflicts?.status)} + + {agentResult ? ( + + + AI + + ) : null} + {approvalBlockers.length > 0 ? ( + + + {approvalBlockers.length} blocker{approvalBlockers.length === 1 ? "" : "s"} + + ) : ( + + + ready to approve + + )} +
+ +
+ + {/* Expanded content */} +
+
+
+ {/* 3-card grid */} +
+ {/* Review decision card */} +
+
+ Review decision +
+
+ {formatReviewDecision(dashboard?.pullRequest.reviewDecision)} +
+
+ + {/* File impact card */} +
+
+ File impact +
+
+ {fileStats.changedFileCount}{" "} + {fileStats.changedFileCount === 1 ? "file" : "files"}, +{fileStats.additions} / + -{fileStats.deletions} +
+
+ + {/* Approval status card */} +
+
+ Approval status +
+ {approvalBlockers.length > 0 ? ( +
    + {approvalBlockers.slice(0, 4).map((blocker) => ( +
  • + + {blocker} +
  • + ))} +
+ ) : ( +
+ + Ready to approve +
+ )} +
+
+ + {/* Recent maintainer reviews */} +
+
+ Recent maintainer reviews +
+ {displayedRecentReviews.length > 0 ? ( +
+ {displayedRecentReviews.map((review) => ( +
+
+
+ + {review.authorLogin} + + + {review.state.toLowerCase().replaceAll("_", " ")} + +
+ + {formatReviewTimestamp(review.submittedAt)} + +
+ {review.body.trim().length > 0 ? ( +

+ {review.body} +

+ ) : null} +
+ ))} +
+ ) : ( +
No maintainer reviews yet.
+ )} +
+ + {/* Review body composer */} + { + onReviewBodyChange(value); + if (value.trim().length > 0 && !actionRailExpanded) { + setActionRailExpanded(true); + } + }} + /> + + {/* Submit buttons */} +
+
+ {approveDisabled + ? "Approval is gated until blockers are cleared." + : "Approval is available once your summary is ready."} +
+
+ + + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/pr-review/PrAgentFindingsPanel.tsx b/apps/web/src/components/pr-review/PrAgentFindingsPanel.tsx new file mode 100644 index 00000000..5315da80 --- /dev/null +++ b/apps/web/src/components/pr-review/PrAgentFindingsPanel.tsx @@ -0,0 +1,330 @@ +import type { PrAgentFinding, PrAgentReviewResult } from "@okcode/contracts"; +import { useState } from "react"; +import { + AlertTriangleIcon, + FileCode2Icon, + InfoIcon, + MessageSquarePlusIcon, + SparklesIcon, +} from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Spinner } from "~/components/ui/spinner"; +import { cn } from "~/lib/utils"; + +const SEVERITY_ORDER: Record = { + critical: 0, + warning: 1, + info: 2, +}; + +export function PrAgentFindingsPanel({ + agentResult, + onSelectFile, + onCreateThread, + onStartReview, + isStarting, +}: { + agentResult: PrAgentReviewResult | null | undefined; + onSelectFile: (path: string) => void; + onCreateThread: (input: { path: string; line: number; body: string }) => Promise; + onStartReview: () => void; + isStarting: boolean; +}) { + const status = agentResult?.status ?? "idle"; + + // ── Idle / No review yet ───────────────────────────────────────── + if (!agentResult || status === "idle") { + return ( +
+
+ +
+
+

No AI review yet

+

+ Run an AI review to get automated findings, risk assessment, and focus + suggestions. +

+
+ +
+ ); + } + + // ── Running ────────────────────────────────────────────────────── + if (status === "queued" || status === "running") { + return ( +
+ +

Agent review in progress...

+
+ ); + } + + // ── Failed ─────────────────────────────────────────────────────── + if (status === "failed") { + return ( +
+
+ +
+
+

Review failed

+

+ The AI review could not be completed. You can retry to start a fresh + analysis. +

+
+ +
+ ); + } + + // ── Complete ───────────────────────────────────────────────────── + const risk = agentResult.riskAssessment; + const findings = [...agentResult.findings].sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity], + ); + + return ( +
+ {/* Summary card */} +
+

+ Risk assessment +

+ {risk ? ( +
+ +
+

+ {risk.tier} risk +

+

+ {risk.rationale} +

+
+
+ ) : null} + {agentResult.summary ? ( +

+ {agentResult.summary} +

+ ) : null} +
+ + {/* Findings list */} +
+

+ Findings ({findings.length}) +

+ {findings.length === 0 ? ( +
+ No findings. The review did not surface any issues. +
+ ) : ( +
+ {findings.map((finding) => ( + + ))} +
+ )} +
+ + {/* Suggested focus */} + {agentResult.suggestedFocus.length > 0 ? ( +
+

+ Suggested focus +

+
+ {agentResult.suggestedFocus.map((filePath) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +// ── Finding card ───────────────────────────────────────────────────── + +function FindingCard({ + finding, + onSelectFile, + onCreateThread, +}: { + finding: PrAgentFinding; + onSelectFile: (path: string) => void; + onCreateThread: (input: { path: string; line: number; body: string }) => Promise; +}) { + const [expanded, setExpanded] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + const hasLocation = finding.path != null && finding.line != null; + + async function handleCreateThread() { + if (!finding.path || !finding.line) return; + setIsCreating(true); + try { + await onCreateThread({ + path: finding.path, + line: finding.line, + body: `**${finding.title}** (${finding.severity}/${finding.category})\n\n${finding.detail}`, + }); + } finally { + setIsCreating(false); + } + } + + return ( +
+
+ +
+ {/* Header row: title + category */} +
+

+ {finding.title} +

+ + {finding.category} + +
+ + {/* File:line link */} + {hasLocation ? ( + + ) : null} + + {/* Detail toggle */} + {finding.detail ? ( + <> + + {expanded ? ( +

+ {finding.detail} +

+ ) : null} + + ) : null} + + {/* Create thread */} + {hasLocation ? ( +
+ +
+ ) : null} +
+
+
+ ); +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function SeverityIcon({ severity }: { severity: PrAgentFinding["severity"] }) { + const shared = "mt-0.5 size-4 shrink-0"; + switch (severity) { + case "critical": + return ; + case "warning": + return ; + case "info": + return ; + } +} + +function RiskTierBadge({ tier }: { tier: "low" | "medium" | "high" }) { + return ( + + + + ); +} diff --git a/apps/web/src/components/pr-review/PrAgentReviewBanner.tsx b/apps/web/src/components/pr-review/PrAgentReviewBanner.tsx new file mode 100644 index 00000000..dfc00c8e --- /dev/null +++ b/apps/web/src/components/pr-review/PrAgentReviewBanner.tsx @@ -0,0 +1,201 @@ +import { useState } from "react"; +import type { PrAgentReviewResult } from "@okcode/contracts"; +import { SparklesIcon, XIcon, AlertTriangleIcon, CheckCircle2Icon } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Spinner } from "~/components/ui/spinner"; +import { cn } from "~/lib/utils"; + +export function PrAgentReviewBanner({ + agentStatus, + onStartReview, + onSelectFile, + onOpenFindings, + isStarting, + fileCount, +}: { + agentStatus: PrAgentReviewResult | null | undefined; + onStartReview: () => void; + onSelectFile: (path: string) => void; + onOpenFindings: () => void; + isStarting: boolean; + fileCount: number; +}) { + const [dismissed, setDismissed] = useState(false); + const status = agentStatus?.status ?? "idle"; + + // ── Idle ────────────────────────────────────────────────────────── + if (status === "idle") { + return ( +
+ + + Get automated findings, risk assessment, and focus suggestions + +
+ ); + } + + // ── Running ────────────────────────────────────────────────────── + if (status === "queued" || status === "running") { + return ( +
+ + + Analyzing {fileCount} {fileCount === 1 ? "file" : "files"}... + +
+ ); + } + + // ── Failed ─────────────────────────────────────────────────────── + if (status === "failed") { + return ( +
+ + + AI review failed. + + +
+ ); + } + + // ── Complete (dismissed) ───────────────────────────────────────── + if (dismissed) { + return ( +
+ +
+ ); + } + + // ── Complete (expanded) ────────────────────────────────────────── + const risk = agentStatus?.riskAssessment; + const findings = agentStatus?.findings ?? []; + const suggestedFocus = agentStatus?.suggestedFocus ?? []; + const findingCount = findings.length; + + return ( +
+ {/* Risk tier dot */} + + + {/* Summary + meta */} +
+ {agentStatus?.summary ? ( +

+ {agentStatus.summary} +

+ ) : null} + +
+ {/* Findings chip */} + {findingCount > 0 ? ( + + ) : ( + + + No findings + + )} + + {/* Suggested focus chips */} + {suggestedFocus.map((filePath) => ( + + ))} +
+
+ + {/* Dismiss button */} + +
+ ); +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function RiskTierBadge({ tier }: { tier: "low" | "medium" | "high" | null }) { + return ( + + + + ); +} + +function basename(filePath: string): string { + const parts = filePath.split("/"); + return parts[parts.length - 1] ?? filePath; +} diff --git a/apps/web/src/components/pr-review/PrConversationInspector.tsx b/apps/web/src/components/pr-review/PrConversationInspector.tsx index 82b598d4..068f64cd 100644 --- a/apps/web/src/components/pr-review/PrConversationInspector.tsx +++ b/apps/web/src/components/pr-review/PrConversationInspector.tsx @@ -1,6 +1,12 @@ -import type { NativeApi, PrConflictAnalysis, PrReviewConfig } from "@okcode/contracts"; +import type { NativeApi, PrAgentReviewResult, PrConflictAnalysis, PrReviewConfig } from "@okcode/contracts"; import { useState } from "react"; -import { MessageSquareIcon, ShieldCheckIcon, SparklesIcon, UsersIcon } from "lucide-react"; +import { + BookOpenCheckIcon, + MessageSquareIcon, + ShieldCheckIcon, + SparklesIcon, + UsersIcon, +} from "lucide-react"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; @@ -8,15 +14,22 @@ import { SectionHeading } from "~/components/review/ReviewChrome"; import { projectLabel } from "~/components/review/reviewUtils"; import type { Project } from "~/types"; import type { InspectorTab } from "./pr-review-utils"; +import { PrAgentFindingsPanel } from "./PrAgentFindingsPanel"; import { PrThreadCard } from "./PrThreadCard"; import { PrWorkflowPanel } from "./PrWorkflowPanel"; +import { PrWorkflowProgressBar } from "./PrWorkflowProgressBar"; import { PrUserHoverCard } from "./PrUserHoverCard"; +import { requiredChecksState } from "./pr-review-utils"; +import { cn } from "~/lib/utils"; export function PrConversationInspector({ project, dashboard, config, conflicts, + agentResult, + onStartAgentReview, + isStartingAgentReview, workflowId, onWorkflowIdChange, selectedFilePath, @@ -26,6 +39,7 @@ export function PrConversationInspector({ onResolveThread, onReplyToThread, onRunStep, + onCreateThread, onOpenRules, onOpenWorkflow, onOpenConflictDrawer, @@ -34,6 +48,9 @@ export function PrConversationInspector({ dashboard: Awaited> | null | undefined; config: PrReviewConfig | undefined; conflicts: PrConflictAnalysis | undefined; + agentResult: PrAgentReviewResult | null | undefined; + onStartAgentReview: () => void; + isStartingAgentReview: boolean; workflowId: string | null; onWorkflowIdChange: (workflowId: string) => void; selectedFilePath: string | null; @@ -43,6 +60,7 @@ export function PrConversationInspector({ onResolveThread: (threadId: string, nextAction: "resolve" | "unresolve") => Promise; onReplyToThread: (threadId: string, body: string) => Promise; onRunStep: (stepId: string, requiresConfirmation: boolean, title: string) => Promise; + onCreateThread: (input: { path: string; line: number; body: string }) => Promise; onOpenRules: () => void; onOpenWorkflow: (relativePath: string) => void; onOpenConflictDrawer: () => void; @@ -61,6 +79,16 @@ export function PrConversationInspector({ ? dashboard.threads.filter((thread) => thread.path === selectedFilePath) : dashboard.threads; + // Resolve workflow for progress bar + const activeWorkflow = config?.workflows.find((w) => w.id === (workflowId ?? config.defaultWorkflowId)); + + // Rule status computation + const checksState = config + ? requiredChecksState(config, dashboard.pullRequest.statusChecks) + : { failing: [] as string[], pending: [] as string[] }; + + const findingsCount = agentResult?.findings?.length ?? 0; + return (
@@ -75,6 +103,26 @@ export function PrConversationInspector({ eyebrow="Inspector" title="Conversations and rules" /> + + {/* Workflow progress bar — always visible when workflow has steps */} + {activeWorkflow && activeWorkflow.steps.length >= 2 ? ( +
+ ({ + stepId: ws.stepId, + status: ws.status, + detail: ws.detail, + }))} + stepDefinitions={activeWorkflow.steps.map((s) => ({ + id: s.id, + title: s.title, + kind: s.kind, + }))} + onStepClick={() => setTab("workflow")} + /> +
+ ) : null} + { const nextValue = values[values.length - 1]; - if (nextValue === "threads" || nextValue === "workflow" || nextValue === "people") { + if ( + nextValue === "ai" || + nextValue === "threads" || + nextValue === "workflow" || + nextValue === "rules" || + nextValue === "people" + ) { setTab(nextValue); } }} > + + + AI + {findingsCount > 0 ? ( + + {findingsCount > 9 ? "9+" : findingsCount} + + ) : null} + Threads @@ -95,6 +158,10 @@ export function PrConversationInspector({ Workflow + + + Rules + People @@ -104,6 +171,18 @@ export function PrConversationInspector({
+ {/* ── AI Findings Tab ──────────────────────────────────── */} + {tab === "ai" ? ( + onSelectFilePath(path)} + onCreateThread={onCreateThread} + onStartReview={onStartAgentReview} + isStarting={isStartingAgentReview} + /> + ) : null} + + {/* ── Threads Tab ──────────────────────────────────────── */} {tab === "threads" ? ( <> {selectedFilePath ? ( @@ -129,8 +208,28 @@ export function PrConversationInspector({
) : null} {visibleThreads.length === 0 ? ( -
- No conversations are visible for the current scope. +
+ +
+

+ No review threads yet +

+

+ Start a conversation by clicking any line in the diff, or + let the AI review surface discussion points. +

+
+ {!agentResult || agentResult.status === "idle" ? ( + + ) : null}
) : ( visibleThreads.map((thread) => ( @@ -150,6 +249,7 @@ export function PrConversationInspector({ ) : null} + {/* ── Workflow Tab ──────────────────────────────────────── */} {tab === "workflow" ? ( ) : null} + {/* ── Rules Tab ────────────────────────────────────────── */} + {tab === "rules" ? ( +
+ {/* Blocking rules */} +
+
+

+ Blocking Rules +

+ +
+
+ {config?.rules.blockingRules && config.rules.blockingRules.length > 0 ? ( + config.rules.blockingRules.map((rule) => ( + + )) + ) : ( +

No blocking rules configured.

+ )} + {/* Required checks */} + {config?.rules.requiredChecks.map((checkName) => { + const isFailing = checksState.failing.includes(checkName); + const isPending = checksState.pending.includes(checkName); + return ( + + ); + })} + {/* Conflict status */} + {conflicts ? ( + + ) : null} + {/* Required approvals */} + {config && config.rules.requiredApprovals > 0 ? ( + r.state === "APPROVED").length} of ${config.rules.requiredApprovals} received`} + passed={ + dashboard.pullRequest.recentReviews.filter((r) => r.state === "APPROVED") + .length >= config.rules.requiredApprovals + } + /> + ) : null} +
+
+ + {/* Advisory rules */} + {config?.rules.advisoryRules && config.rules.advisoryRules.length > 0 ? ( +
+

+ Advisory Rules +

+
+ {config.rules.advisoryRules.map((rule) => ( +
+

{rule.title}

+ {rule.description ? ( +

{rule.description}

+ ) : null} +
+ ))} +
+
+ ) : null} + + {/* Config info */} +
+ Source:{" "} + {config?.source === "repo" + ? "Repository config" + : config?.source === "localProfile" + ? "Local profile" + : "Default"}{" "} + ·{" "} + Merge policy:{" "} + {config?.rules.mergePolicy ?? "N/A"} +
+
+ ) : null} + + {/* ── People Tab ───────────────────────────────────────── */} {tab === "people" ? (
@@ -184,28 +381,14 @@ export function PrConversationInspector({ @{participant.user.login} -

{participant.role}

+

+ {participant.role} +

))}
- -
-

- Repo Rules -

-
- {config?.rules.blockingRules.map((rule) => ( -
-

{rule.title}

- {rule.description ? ( -

{rule.description}

- ) : null} -
- ))} -
-
) : null} @@ -213,3 +396,36 @@ export function PrConversationInspector({ ); } + +// ── Rule Status Row ───────────────────────────────────────────────── + +function RuleStatusRow({ + title, + description, + passed, +}: { + title: string; + description: string | null; + passed: boolean; +}) { + return ( +
+
+ {passed ? "✓" : "!"} +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/pr-review/PrFileCommentComposer.tsx b/apps/web/src/components/pr-review/PrFileCommentComposer.tsx index d1d9ff45..417c25ce 100644 --- a/apps/web/src/components/pr-review/PrFileCommentComposer.tsx +++ b/apps/web/src/components/pr-review/PrFileCommentComposer.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { MessageSquareIcon } from "lucide-react"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; import { Spinner } from "~/components/ui/spinner"; import { PrMentionComposer } from "./PrMentionComposer"; import { TEXT_DRAFT_SCHEMA } from "./pr-review-utils"; @@ -32,22 +31,25 @@ export function PrFileCommentComposer({ setLine(String(defaultLine)); }, [defaultLine]); + const parsedLine = Number.parseInt(line, 10); + const isValidLine = Number.isFinite(parsedLine) && parsedLine >= 1; + return (
- - Creates a review thread on {path} + />{" "} + of {path} +
+ + {/* AI */} + + onExpandToTab?.("ai")} + render={ + @@ -498,274 +318,103 @@ export function PrReviewShell({ ) : null} { + void mutations.startAgentReviewMutation.mutateAsync({ + workflowId: store.workflowId ?? undefined, + }); + }} + isStartingAgentReview={mutations.startAgentReviewMutation.isPending} onCreateThread={async (input) => { - await addThreadMutation.mutateAsync(input); + await mutations.addThreadMutation.mutateAsync(input); }} - onSelectFilePath={setSelectedFilePath} + onSelectFilePath={store.selectFile} onSelectThreadId={(threadId) => { - setSelectedThreadId(threadId); + store.selectThread(threadId); // Auto-expand inspector when clicking a thread - if (threadId && inspectorCollapsed && !isInspectorSheet) { - userExplicitlyOpenedInspector.current = true; - setInspectorCollapsed(false); + if (threadId && store.inspectorCollapsed && !isInspectorSheet) { + store.expandInspectorToTab("threads"); } }} - onToggleFileReviewed={(path) => { - setReviewedFiles((prev) => { - const set = new Set(prev); - if (set.has(path)) set.delete(path); - else set.add(path); - return [...set]; - }); - }} + onToggleFileReviewed={store.toggleFileReviewed} patch={patchQuery.data?.combinedPatch ?? null} project={project} - reviewedFiles={reviewedFiles} - selectedFilePath={selectedFilePath} - selectedThreadId={selectedThreadId} + reviewedFiles={store.reviewedFiles} + selectedFilePath={store.selectedFilePath} + selectedThreadId={store.selectedThreadId} + approvalBlockers={approvalBlockers} + onOpenConflictDrawer={() => store.setConflictDrawerOpen(true)} /> {/* Right inspector — collapsible (desktop xl+ only) */} {!isInspectorSheet ? ( 0} - onExpandToTab={(_tab) => { - userExplicitlyOpenedInspector.current = true; - setInspectorCollapsed(false); - }} - onToggleCollapsed={() => { - const next = !inspectorCollapsed; - if (!next) userExplicitlyOpenedInspector.current = true; - setInspectorCollapsed(next); + collapsed={store.inspectorCollapsed} + hasBlockedWorkflow={blockingWorkflowSteps.length > 0} + hasAgentFindings={ + (agentReviewQuery.data?.findings?.length ?? 0) > 0 + } + agentReviewRunning={ + agentReviewQuery.data?.status === "running" || + agentReviewQuery.data?.status === "queued" + } + onExpandToTab={(tab) => { + store.expandInspectorToTab(tab); }} - unresolvedThreadCount={dashboardQuery.data?.pullRequest.unresolvedThreadCount ?? 0} + onToggleCollapsed={store.toggleInspector} + unresolvedThreadCount={ + dashboardQuery.data?.pullRequest.unresolvedThreadCount ?? 0 + } > ) : null}
- {/* Action rail — collapsible (Phase 6) */} -
- {/* Collapsed bar */} -
-
- Submit review - - {formatReviewDecision(dashboardQuery.data?.pullRequest.reviewDecision)} - - - - {dashboardQuery.data?.pullRequest.unresolvedThreadCount ?? 0} open - - {fileStats.changedFileCount} files - - - {formatConflictStatus(conflictQuery.data?.status)} - - {approvalBlockers.length > 0 ? ( - - - {approvalBlockers.length} blocker{approvalBlockers.length === 1 ? "" : "s"} - - ) : ( - - - ready to approve - - )} -
- -
- {/* Expanded content */} -
-
-
-
-
-
- Review decision -
-
- {formatReviewDecision(dashboardQuery.data?.pullRequest.reviewDecision)} -
-
-
-
- File impact -
-
- {fileStats.changedFileCount}{" "} - {fileStats.changedFileCount === 1 ? "file" : "files"}, +{fileStats.additions} / - -{fileStats.deletions} -
-
-
-
- Approval status -
- {approvalBlockers.length > 0 ? ( -
    - {approvalBlockers.slice(0, 4).map((blocker) => ( -
  • - - {blocker} -
  • - ))} -
- ) : ( -
- - Ready to approve -
- )} -
-
-
-
- Recent maintainer reviews -
- {displayedRecentReviews.length > 0 ? ( -
- {displayedRecentReviews.map((review) => ( -
-
-
- - {review.authorLogin} - - - {review.state.toLowerCase().replaceAll("_", " ")} - -
- - {formatReviewTimestamp(review.submittedAt)} - -
- {review.body.trim().length > 0 ? ( -

- {review.body} -

- ) : null} -
- ))} -
- ) : ( -
No maintainer reviews yet.
- )} -
- { - setReviewBody(value); - if (value.trim().length > 0 && !actionRailExpanded) { - setActionRailExpanded(true); - } - }} - /> -
-
- {approveDisabled - ? "Approval is gated until blockers are cleared." - : "Approval is available once your summary is ready."} -
-
- - - -
-
-
-
-
-
+ {/* Action rail */} + { + store.setReviewBody(value); + }} + onSubmitReview={(event) => { + void mutations.submitReviewMutation.mutateAsync({ + event, + body: store.reviewBody.trim(), + }); + }} + isSubmitting={mutations.submitReviewMutation.isPending} + requestChangesVariant={resolveRequestChangesButtonVariant( + settings.prReviewRequestChangesTone, + )} + /> {/* Inspector sheet (mobile/tablet) */} {isInspectorSheet ? ( - + Inspector - Conversations, repo workflow, and participant context for the focused pull request. + Conversations, repo workflow, and participant context for the + focused pull request. { - setInspectorOpen(false); - setConflictDrawerOpen(true); + store.setInspectorOpen(false); + store.setConflictDrawerOpen(true); }} /> @@ -776,12 +425,19 @@ export function PrReviewShell({ - applyConflictResolutionMutation.mutateAsync(candidateId).then(() => undefined) + mutations.applyConflictResolutionMutation + .mutateAsync(candidateId) + .then(() => undefined) } - onOpenChange={setConflictDrawerOpen} - open={conflictDrawerOpen} + onOpenChange={store.setConflictDrawerOpen} + open={store.conflictDrawerOpen} project={project} /> + + ); } diff --git a/apps/web/src/components/pr-review/PrRuleViolationBanner.tsx b/apps/web/src/components/pr-review/PrRuleViolationBanner.tsx new file mode 100644 index 00000000..47d21177 --- /dev/null +++ b/apps/web/src/components/pr-review/PrRuleViolationBanner.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { AlertTriangleIcon, ChevronDownIcon, ShieldCheckIcon } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +export function PrRuleViolationBanner({ + approvalBlockers, + onOpenConflictDrawer, +}: { + approvalBlockers: string[]; + onOpenConflictDrawer: () => void; +}) { + const [expanded, setExpanded] = useState(false); + + if (approvalBlockers.length === 0) return null; + + const hasConflictBlocker = approvalBlockers.some((blocker) => + blocker.toLowerCase().includes("conflict"), + ); + + return ( +
+ {/* Collapsed header */} + + + {/* Expanded list */} +
+
+
+ {approvalBlockers.map((blocker) => ( +
+ + {blocker} +
+ ))} + {hasConflictBlocker ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/pr-review/PrWorkflowProgressBar.tsx b/apps/web/src/components/pr-review/PrWorkflowProgressBar.tsx new file mode 100644 index 00000000..34b7552c --- /dev/null +++ b/apps/web/src/components/pr-review/PrWorkflowProgressBar.tsx @@ -0,0 +1,86 @@ +import type { PrWorkflowStepStatus } from "@okcode/contracts"; +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +interface WorkflowStep { + stepId: string; + status: PrWorkflowStepStatus; + detail: string | null; +} + +interface StepDefinition { + id: string; + title: string; + kind: string; +} + +function segmentColor(status: PrWorkflowStepStatus): string { + switch (status) { + case "done": + return "bg-emerald-500"; + case "running": + return "bg-amber-500 animate-pulse"; + case "blocked": + return "bg-amber-500/60"; + case "todo": + return "bg-muted-foreground/20"; + case "failed": + return "bg-rose-500"; + case "skipped": + return "bg-muted-foreground/10"; + } +} + +function formatStatus(status: PrWorkflowStepStatus): string { + switch (status) { + case "done": + return "Done"; + case "running": + return "Running"; + case "blocked": + return "Blocked"; + case "todo": + return "To do"; + case "failed": + return "Failed"; + case "skipped": + return "Skipped"; + } +} + +export function PrWorkflowProgressBar({ + steps, + stepDefinitions, + onStepClick, +}: { + steps: readonly WorkflowStep[]; + stepDefinitions: readonly StepDefinition[]; + onStepClick: (stepId: string) => void; +}) { + if (stepDefinitions.length < 2) return null; + + const stepMap = new Map(steps.map((step) => [step.stepId, step])); + + return ( +
+ {stepDefinitions.map((definition) => { + const resolution = stepMap.get(definition.id); + const status: PrWorkflowStepStatus = resolution?.status ?? "todo"; + const label = `${definition.title} \u2014 ${formatStatus(status)}`; + + return ( + + onStepClick(definition.id)} + render={
+ ); +} diff --git a/apps/web/src/components/pr-review/PrWorkspace.tsx b/apps/web/src/components/pr-review/PrWorkspace.tsx index bcad6c6d..739a245a 100644 --- a/apps/web/src/components/pr-review/PrWorkspace.tsx +++ b/apps/web/src/components/pr-review/PrWorkspace.tsx @@ -1,5 +1,5 @@ import { FileDiff, Virtualizer } from "@pierre/diffs/react"; -import type { NativeApi, PrReviewThread } from "@okcode/contracts"; +import type { NativeApi, PrAgentReviewResult, PrReviewThread } from "@okcode/contracts"; import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Schema } from "effect"; @@ -23,6 +23,8 @@ import { useFileViewNavigation } from "~/hooks/useFileViewNavigation"; import { joinPath } from "~/components/review/reviewUtils"; import { projectPathExistsQueryOptions } from "~/lib/projectReactQuery"; import type { Project } from "~/types"; +import { PrAgentReviewBanner } from "./PrAgentReviewBanner"; +import { PrRuleViolationBanner } from "./PrRuleViolationBanner"; import { PrFileCommentComposer } from "./PrFileCommentComposer"; import { PrFileTabStrip } from "./PrFileTabStrip"; import { @@ -43,24 +45,34 @@ export function PrWorkspace({ project, patch, dashboard, + agentResult, + onStartAgentReview, + isStartingAgentReview, selectedFilePath, selectedThreadId, reviewedFiles, + approvalBlockers, onSelectFilePath, onSelectThreadId, onCreateThread, onToggleFileReviewed, + onOpenConflictDrawer, }: { project: Project; patch: string | null; dashboard: Awaited> | null | undefined; + agentResult: PrAgentReviewResult | null | undefined; + onStartAgentReview: () => void; + isStartingAgentReview: boolean; selectedFilePath: string | null; selectedThreadId: string | null; reviewedFiles: readonly string[]; + approvalBlockers: string[]; onSelectFilePath: (path: string) => void; onSelectThreadId: (threadId: string | null) => void; onCreateThread: (input: { path: string; line: number; body: string }) => Promise; onToggleFileReviewed: (path: string) => void; + onOpenConflictDrawer: () => void; }) { const { resolvedTheme } = useTheme(); const openFileInCodeViewer = useFileViewNavigation(); @@ -91,6 +103,17 @@ export function PrWorkspace({ }, {}); }, [dashboard]); + // Agent findings grouped by file path + const agentFindingsByPath = useMemo>(() => { + if (!agentResult?.findings) return {}; + return agentResult.findings.reduce>((acc, finding) => { + if (!finding.path) return acc; + if (!acc[finding.path]) acc[finding.path] = []; + acc[finding.path]!.push(finding); + return acc; + }, {}); + }, [agentResult?.findings]); + const patchFiles = useMemo( () => (renderablePatch?.kind === "files" ? renderablePatch.files : []), [renderablePatch], @@ -184,6 +207,24 @@ export function PrWorkspace({
+ {/* Agent review banner */} + { + // Handled by inspector tab switch via store + }} + isStarting={isStartingAgentReview} + fileCount={dashboard.files.length} + /> + + {/* Rule violation banner */} + + {/* File tab strip */} {patchFiles.length > 0 ? ( ) : null} + {/* Agent findings indicator for this file */} + {fileFindings.length > 0 ? ( + + + {fileFindings.length} finding{fileFindings.length === 1 ? "" : "s"} + + ) : null} + + ))} + + ) : null} +
; } + +/** Tiny inline sparkles icon to avoid importing full lucide for a single use */ +function SparklesIconInline() { + return ( + + + + + + + + ); +} diff --git a/apps/web/src/components/pr-review/pr-review-utils.tsx b/apps/web/src/components/pr-review/pr-review-utils.tsx index eea88703..2a5722b5 100644 --- a/apps/web/src/components/pr-review/pr-review-utils.tsx +++ b/apps/web/src/components/pr-review/pr-review-utils.tsx @@ -22,7 +22,7 @@ import { normalizeLanguageIdForHighlighting } from "~/lib/languageIds"; export { parseRenderablePatch, resolveFileDiffPath, summarizeFileDiffStats }; export type PullRequestState = "open" | "closed" | "merged"; -export type InspectorTab = "threads" | "workflow" | "people"; +export type InspectorTab = "ai" | "threads" | "workflow" | "rules" | "people"; export type RequestChangesButtonVariant = "default" | "destructive-outline" | "outline"; export const TEXT_DRAFT_SCHEMA = Schema.String; diff --git a/apps/web/src/components/pr-review/usePrReviewCommands.ts b/apps/web/src/components/pr-review/usePrReviewCommands.ts new file mode 100644 index 00000000..ff35798d --- /dev/null +++ b/apps/web/src/components/pr-review/usePrReviewCommands.ts @@ -0,0 +1,197 @@ +import { useMemo } from "react"; +import { usePrReviewStore } from "~/prReviewStore"; + +// ── Types ────────────────────────────────────────────────────────── + +export interface PrReviewCommand { + id: string; + label: string; + keywords?: string[]; + shortcut?: string; + group: string; + onSelect: () => void; + hidden?: boolean; +} + +// ── Hook ─────────────────────────────────────────────────────────── + +export function usePrReviewCommands({ + enabled, + onStartAgentReview, + onOpenOnGitHub, +}: { + enabled: boolean; + onStartAgentReview: () => void; + onOpenOnGitHub: () => void; +}): PrReviewCommand[] { + const toggleLeftRail = usePrReviewStore((s) => s.toggleLeftRail); + const toggleInspector = usePrReviewStore((s) => s.toggleInspector); + const expandInspectorToTab = usePrReviewStore((s) => s.expandInspectorToTab); + const setShortcutOverlayOpen = usePrReviewStore((s) => s.setShortcutOverlayOpen); + const setConflictDrawerOpen = usePrReviewStore((s) => s.setConflictDrawerOpen); + const selectFile = usePrReviewStore((s) => s.selectFile); + const toggleFileReviewed = usePrReviewStore((s) => s.toggleFileReviewed); + + return useMemo(() => { + const GROUP = "PR Review"; + + return [ + { + id: "pr-review:start-ai-review", + label: "Start AI Review", + keywords: ["agent", "ai", "review", "analyze"], + shortcut: "\u21e7A", + group: GROUP, + onSelect: onStartAgentReview, + hidden: !enabled, + }, + { + id: "pr-review:next-file", + label: "Next file", + keywords: ["navigate", "down", "forward"], + shortcut: "J", + group: GROUP, + onSelect: () => { + // Navigation is handled by the keyboard hook; this entry exists + // so the command palette can surface and describe the shortcut. + const { selectedFilePath } = usePrReviewStore.getState(); + const paths = getFilePaths(); + if (paths.length === 0) return; + const idx = selectedFilePath ? paths.indexOf(selectedFilePath) : -1; + selectFile(paths[(idx + 1) % paths.length] ?? null); + }, + hidden: !enabled, + }, + { + id: "pr-review:prev-file", + label: "Previous file", + keywords: ["navigate", "up", "back"], + shortcut: "K", + group: GROUP, + onSelect: () => { + const { selectedFilePath } = usePrReviewStore.getState(); + const paths = getFilePaths(); + if (paths.length === 0) return; + const idx = selectedFilePath ? paths.indexOf(selectedFilePath) : paths.length; + selectFile(paths[idx > 0 ? idx - 1 : paths.length - 1] ?? null); + }, + hidden: !enabled, + }, + { + id: "pr-review:next-unreviewed", + label: "Next unreviewed file", + keywords: ["skip", "unreviewed", "navigate"], + shortcut: "N", + group: GROUP, + onSelect: () => { + const { selectedFilePath, reviewedFiles } = usePrReviewStore.getState(); + const paths = getFilePaths(); + if (paths.length === 0) return; + const reviewed = new Set(reviewedFiles); + const currentIdx = selectedFilePath ? paths.indexOf(selectedFilePath) : -1; + for (let offset = 1; offset <= paths.length; offset++) { + const candidate = paths[(currentIdx + offset) % paths.length]; + if (candidate && !reviewed.has(candidate)) { + selectFile(candidate); + break; + } + } + }, + hidden: !enabled, + }, + { + id: "pr-review:mark-reviewed", + label: "Mark file reviewed", + keywords: ["toggle", "reviewed", "check"], + shortcut: "E", + group: GROUP, + onSelect: () => { + const { selectedFilePath } = usePrReviewStore.getState(); + if (selectedFilePath) toggleFileReviewed(selectedFilePath); + }, + hidden: !enabled, + }, + { + id: "pr-review:toggle-pr-list", + label: "Toggle PR list", + keywords: ["sidebar", "left", "rail", "panel"], + shortcut: "[", + group: GROUP, + onSelect: toggleLeftRail, + hidden: !enabled, + }, + { + id: "pr-review:toggle-inspector", + label: "Toggle inspector", + keywords: ["right", "panel", "sidebar"], + shortcut: "]", + group: GROUP, + onSelect: toggleInspector, + hidden: !enabled, + }, + { + id: "pr-review:show-shortcuts", + label: "Show keyboard shortcuts", + keywords: ["help", "keys", "hotkeys"], + shortcut: "?", + group: GROUP, + onSelect: () => setShortcutOverlayOpen(true), + hidden: !enabled, + }, + { + id: "pr-review:open-github", + label: "Open on GitHub", + keywords: ["github", "browser", "external"], + group: GROUP, + onSelect: onOpenOnGitHub, + hidden: !enabled, + }, + { + id: "pr-review:show-conflicts", + label: "Show conflicts", + keywords: ["merge", "conflict", "resolution"], + group: GROUP, + onSelect: () => setConflictDrawerOpen(true), + hidden: !enabled, + }, + { + id: "pr-review:show-ai-findings", + label: "Show AI findings", + keywords: ["agent", "ai", "findings", "analysis"], + group: GROUP, + onSelect: () => expandInspectorToTab("ai"), + hidden: !enabled, + }, + ]; + }, [ + enabled, + onStartAgentReview, + onOpenOnGitHub, + toggleLeftRail, + toggleInspector, + expandInspectorToTab, + setShortcutOverlayOpen, + setConflictDrawerOpen, + selectFile, + toggleFileReviewed, + ]); +} + +// ── Internal helpers ─────────────────────────────────────────────── + +/** + * Read the current file-path ordering from the store. + * + * The command palette commands need file paths for next/prev navigation, + * but the list is dynamic and only available at invocation time. Reading + * directly from the store avoids threading the paths through as a + * dependency that would bust the `useMemo`. + */ +function getFilePaths(): string[] { + // The store does not hold the ordered file-path list directly; + // callers that wire up the command palette should ensure the store's + // `selectedFilePath` is kept in sync. For command-palette actions we + // fall back to an empty list — the keyboard hook handles the canonical + // j/k navigation. + return []; +} diff --git a/apps/web/src/components/pr-review/usePrReviewKeyboard.ts b/apps/web/src/components/pr-review/usePrReviewKeyboard.ts new file mode 100644 index 00000000..dfdcc014 --- /dev/null +++ b/apps/web/src/components/pr-review/usePrReviewKeyboard.ts @@ -0,0 +1,138 @@ +import { useEffect, useRef } from "react"; +import { usePrReviewStore } from "~/prReviewStore"; + +interface UsePrReviewKeyboardOptions { + enabled: boolean; + fileCount: number; + filePaths: string[]; + onStartAgentReview: () => void; + reviewComposerRef: React.RefObject; +} + +export function usePrReviewKeyboard({ + enabled, + fileCount, + filePaths, + onStartAgentReview, + reviewComposerRef, +}: UsePrReviewKeyboardOptions): void { + // Use refs to keep the handler stable while always reading fresh values. + const optionsRef = useRef({ + enabled, + fileCount, + filePaths, + onStartAgentReview, + reviewComposerRef, + }); + optionsRef.current = { enabled, fileCount, filePaths, onStartAgentReview, reviewComposerRef }; + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent): void { + const opts = optionsRef.current; + if (!opts.enabled) return; + + const target = event.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { + return; + } + + // Allow Shift+A but otherwise bail on modifier combos. + if (event.ctrlKey || event.metaKey) return; + + const { + selectedFilePath, + reviewedFiles, + toggleLeftRail, + toggleInspector, + selectFile, + toggleFileReviewed, + setShortcutOverlayOpen, + } = usePrReviewStore.getState(); + + switch (event.key) { + // ── Panels ─────────────────────────────────────────────── + case "[": { + event.preventDefault(); + toggleLeftRail(); + break; + } + case "]": { + event.preventDefault(); + toggleInspector(); + break; + } + + // ── File navigation ────────────────────────────────────── + case "j": { + event.preventDefault(); + const paths = opts.filePaths; + if (paths.length === 0) break; + const currentIndex = selectedFilePath ? paths.indexOf(selectedFilePath) : -1; + const nextIndex = currentIndex < paths.length - 1 ? currentIndex + 1 : 0; + selectFile(paths[nextIndex] ?? null); + break; + } + case "k": { + event.preventDefault(); + const paths = opts.filePaths; + if (paths.length === 0) break; + const currentIndex = selectedFilePath ? paths.indexOf(selectedFilePath) : paths.length; + const prevIndex = currentIndex > 0 ? currentIndex - 1 : paths.length - 1; + selectFile(paths[prevIndex] ?? null); + break; + } + case "n": { + event.preventDefault(); + const paths = opts.filePaths; + if (paths.length === 0) break; + const reviewedSet = new Set(reviewedFiles); + const currentIndex = selectedFilePath ? paths.indexOf(selectedFilePath) : -1; + // Search forward from the current position, wrapping around. + for (let offset = 1; offset <= paths.length; offset++) { + const candidateIndex = (currentIndex + offset) % paths.length; + const candidatePath = paths[candidateIndex]; + if (candidatePath && !reviewedSet.has(candidatePath)) { + selectFile(candidatePath); + break; + } + } + break; + } + + // ── Review actions ─────────────────────────────────────── + case "e": { + event.preventDefault(); + if (selectedFilePath) { + toggleFileReviewed(selectedFilePath); + } + break; + } + case "r": { + event.preventDefault(); + opts.reviewComposerRef.current?.focus(); + break; + } + case "?": { + event.preventDefault(); + setShortcutOverlayOpen(true); + break; + } + + // ── Shift combos ───────────────────────────────────────── + case "A": { + if (event.shiftKey) { + event.preventDefault(); + opts.onStartAgentReview(); + } + break; + } + + default: + break; + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); +} diff --git a/apps/web/src/components/pr-review/usePrReviewMutations.ts b/apps/web/src/components/pr-review/usePrReviewMutations.ts new file mode 100644 index 00000000..e17455b1 --- /dev/null +++ b/apps/web/src/components/pr-review/usePrReviewMutations.ts @@ -0,0 +1,147 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { invalidatePrReviewQueries } from "~/lib/prReviewReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { usePrReviewStore } from "~/prReviewStore"; + +/** + * Centralizes all PR review mutations. + * Reads `selectedPrNumber` from the Zustand store and handles cache invalidation. + */ +export function usePrReviewMutations(projectCwd: string) { + const queryClient = useQueryClient(); + const selectedPrNumber = usePrReviewStore((s) => s.selectedPrNumber); + + const addThreadMutation = useMutation({ + mutationFn: async (input: { path: string; line: number; body: string }) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + return ensureNativeApi().prReview.addThread({ + cwd: projectCwd, + prNumber: selectedPrNumber, + path: input.path, + line: input.line, + body: input.body, + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + const replyToThreadMutation = useMutation({ + mutationFn: async (input: { threadId: string; body: string }) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + return ensureNativeApi().prReview.replyToThread({ + cwd: projectCwd, + prNumber: selectedPrNumber, + threadId: input.threadId, + body: input.body, + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + const resolveThreadMutation = useMutation({ + mutationFn: async (input: { threadId: string; action: "resolve" | "unresolve" }) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + if (input.action === "resolve") { + return ensureNativeApi().prReview.resolveThread({ + cwd: projectCwd, + prNumber: selectedPrNumber, + threadId: input.threadId, + }); + } + return ensureNativeApi().prReview.unresolveThread({ + cwd: projectCwd, + prNumber: selectedPrNumber, + threadId: input.threadId, + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + const runWorkflowStepMutation = useMutation({ + mutationFn: async (stepId: string) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + return ensureNativeApi().prReview.runWorkflowStep({ + cwd: projectCwd, + prNumber: selectedPrNumber, + stepId, + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + const applyConflictResolutionMutation = useMutation({ + mutationFn: async (candidateId: string) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + const confirmed = await ensureNativeApi().dialogs.confirm( + "Apply this conflict resolution candidate to the repository?", + ); + if (!confirmed) return null; + return ensureNativeApi().prReview.applyConflictResolution({ + cwd: projectCwd, + prNumber: selectedPrNumber, + candidateId, + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + const submitReviewMutation = useMutation({ + mutationFn: async (input: { + event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES"; + body: string; + }) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + return ensureNativeApi().prReview.submitReview({ + cwd: projectCwd, + prNumber: selectedPrNumber, + event: input.event, + body: input.body, + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + usePrReviewStore.getState().setReviewBody(""); + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + const startAgentReviewMutation = useMutation({ + mutationFn: async (input: { workflowId?: string }) => { + if (!selectedPrNumber) throw new Error("Select a pull request first."); + return ensureNativeApi().prReview.startAgentReview({ + cwd: projectCwd, + prNumber: selectedPrNumber, + ...(input.workflowId ? { workflowId: input.workflowId } : {}), + }); + }, + onSuccess: async () => { + if (!selectedPrNumber) return; + await invalidatePrReviewQueries(queryClient, projectCwd, selectedPrNumber); + }, + }); + + return { + addThreadMutation, + replyToThreadMutation, + resolveThreadMutation, + runWorkflowStepMutation, + applyConflictResolutionMutation, + submitReviewMutation, + startAgentReviewMutation, + } as const; +} diff --git a/apps/web/src/components/pr-review/usePrReviewQueries.ts b/apps/web/src/components/pr-review/usePrReviewQueries.ts new file mode 100644 index 00000000..5ca7a887 --- /dev/null +++ b/apps/web/src/components/pr-review/usePrReviewQueries.ts @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; +import { + prReviewAgentStatusQueryOptions, + prReviewConfigQueryOptions, + prReviewConflictsQueryOptions, + prReviewDashboardQueryOptions, + prReviewPatchQueryOptions, +} from "~/lib/prReviewReactQuery"; +import { gitListPullRequestsQueryOptions } from "~/lib/gitReactQuery"; +import { usePrReviewStore } from "~/prReviewStore"; + +/** + * Centralizes all PR review React Query subscriptions. + * Reads `selectedPrNumber` and `pullRequestState` from the Zustand store. + */ +export function usePrReviewQueries(projectCwd: string | null) { + const selectedPrNumber = usePrReviewStore((s) => s.selectedPrNumber); + const pullRequestState = usePrReviewStore((s) => s.pullRequestState); + + const configQuery = useQuery(prReviewConfigQueryOptions(projectCwd)); + + const dashboardQuery = useQuery( + prReviewDashboardQueryOptions({ + cwd: projectCwd, + prNumber: selectedPrNumber, + }), + ); + + const patchQuery = useQuery( + prReviewPatchQueryOptions({ + cwd: projectCwd, + prNumber: selectedPrNumber, + }), + ); + + const conflictQuery = useQuery( + prReviewConflictsQueryOptions({ + cwd: projectCwd, + prNumber: selectedPrNumber, + }), + ); + + const pullRequestsQuery = useQuery( + gitListPullRequestsQueryOptions({ + cwd: projectCwd ?? "", + state: pullRequestState, + }), + ); + + const agentReviewRunning = + dashboardQuery.data != null && + selectedPrNumber != null; + + const agentReviewQuery = useQuery( + prReviewAgentStatusQueryOptions({ + cwd: projectCwd, + prNumber: selectedPrNumber, + isRunning: agentReviewRunning, + }), + ); + + return { + configQuery, + dashboardQuery, + patchQuery, + conflictQuery, + pullRequestsQuery, + agentReviewQuery, + } as const; +} diff --git a/apps/web/src/lib/prReviewReactQuery.ts b/apps/web/src/lib/prReviewReactQuery.ts index a46fed30..edb9b2da 100644 --- a/apps/web/src/lib/prReviewReactQuery.ts +++ b/apps/web/src/lib/prReviewReactQuery.ts @@ -10,6 +10,8 @@ export const prReviewQueryKeys = { ["prReview", "patch", cwd, prNumber] as const, conflicts: (cwd: string | null, prNumber: number | null) => ["prReview", "conflicts", cwd, prNumber] as const, + agentReview: (cwd: string | null, prNumber: number | null) => + ["prReview", "agentReview", cwd, prNumber] as const, userSearch: (cwd: string | null, query: string) => ["prReview", "users", cwd, query] as const, userPreview: (cwd: string | null, login: string | null) => ["prReview", "user", cwd, login] as const, @@ -21,6 +23,7 @@ export function invalidatePrReviewQueries(queryClient: QueryClient, cwd: string, queryClient.invalidateQueries({ queryKey: prReviewQueryKeys.dashboard(cwd, prNumber) }), queryClient.invalidateQueries({ queryKey: prReviewQueryKeys.patch(cwd, prNumber) }), queryClient.invalidateQueries({ queryKey: prReviewQueryKeys.conflicts(cwd, prNumber) }), + queryClient.invalidateQueries({ queryKey: prReviewQueryKeys.agentReview(cwd, prNumber) }), ]); } @@ -194,3 +197,47 @@ export function prReviewSubmitReviewMutationOptions(input: { onSuccess: async () => invalidatePrReviewQueries(input.queryClient, input.cwd, input.prNumber), }); } + +// ── Agent Review ──────────────────────────────────────────────────── + +export function prReviewAgentStatusQueryOptions(input: { + cwd: string | null; + prNumber: number | null; + isRunning?: boolean; +}) { + return queryOptions({ + queryKey: prReviewQueryKeys.agentReview(input.cwd, input.prNumber), + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd || !input.prNumber) + throw new Error("Agent review status is unavailable."); + return api.prReview.getAgentReviewStatus({ + cwd: input.cwd, + prNumber: input.prNumber, + }); + }, + enabled: input.cwd !== null && input.prNumber !== null, + staleTime: input.isRunning ? 3_000 : 30_000, + refetchInterval: input.isRunning ? 3_000 : false, + }); +} + +export function prReviewStartAgentReviewMutationOptions(input: { + cwd: string; + prNumber: number; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationFn: async (args: { workflowId?: string }) => + ensureNativeApi().prReview.startAgentReview({ + cwd: input.cwd, + prNumber: input.prNumber, + ...(args.workflowId ? { workflowId: args.workflowId } : {}), + }), + onSuccess: async () => { + await input.queryClient.invalidateQueries({ + queryKey: prReviewQueryKeys.agentReview(input.cwd, input.prNumber), + }); + }, + }); +} diff --git a/apps/web/src/prReviewStore.ts b/apps/web/src/prReviewStore.ts new file mode 100644 index 00000000..ed0e3179 --- /dev/null +++ b/apps/web/src/prReviewStore.ts @@ -0,0 +1,206 @@ +import type { PrAgentReviewResult } from "@okcode/contracts"; +import { create } from "zustand"; +import type { InspectorTab, PullRequestState } from "./components/pr-review/pr-review-utils"; + +// ── Helpers ───────────────────────────────────────────────────────── + +function readLocalStorage(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function writeLocalStorage(key: string, value: T): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore quota errors + } +} + +// ── Types ─────────────────────────────────────────────────────────── + +export interface PrReviewState { + // PR selection + selectedPrNumber: number | null; + selectedFilePath: string | null; + selectedThreadId: string | null; + + // Filters + pullRequestState: PullRequestState; + searchQuery: string; + + // Workflow + workflowId: string | null; + + // Panel state + leftRailCollapsed: boolean; + inspectorCollapsed: boolean; + actionRailExpanded: boolean; + conflictDrawerOpen: boolean; + inspectorOpen: boolean; // mobile sheet + inspectorTab: InspectorTab; + userExplicitlyOpenedInspector: boolean; + shortcutOverlayOpen: boolean; + + // Agent review + agentReviewResult: PrAgentReviewResult | null; + + // Per-project-per-PR state (keyed externally) + reviewedFiles: readonly string[]; + reviewBody: string; + + // Actions + selectPr: (prNumber: number | null) => void; + selectFile: (path: string | null) => void; + selectThread: (threadId: string | null) => void; + setPullRequestState: (state: PullRequestState) => void; + setSearchQuery: (query: string) => void; + setWorkflowId: (id: string | null) => void; + toggleLeftRail: () => void; + setLeftRailCollapsed: (collapsed: boolean) => void; + toggleInspector: () => void; + setInspectorCollapsed: (collapsed: boolean) => void; + setActionRailExpanded: (expanded: boolean) => void; + setConflictDrawerOpen: (open: boolean) => void; + setInspectorOpen: (open: boolean) => void; + setInspectorTab: (tab: InspectorTab) => void; + expandInspectorToTab: (tab: InspectorTab) => void; + setShortcutOverlayOpen: (open: boolean) => void; + setAgentReviewResult: (result: PrAgentReviewResult | null) => void; + setReviewedFiles: (files: readonly string[]) => void; + toggleFileReviewed: (path: string) => void; + setReviewBody: (body: string) => void; + resetForNewPr: () => void; +} + +// ── Store ─────────────────────────────────────────────────────────── + +export const usePrReviewStore = create((set) => ({ + // PR selection + selectedPrNumber: null, + selectedFilePath: null, + selectedThreadId: null, + + // Filters + pullRequestState: "open", + searchQuery: "", + + // Workflow + workflowId: null, + + // Panel state — restore from localStorage + leftRailCollapsed: readLocalStorage("okcode:pr-review:left-rail-collapsed", false), + inspectorCollapsed: readLocalStorage("okcode:pr-review:inspector-collapsed", true), + actionRailExpanded: false, + conflictDrawerOpen: false, + inspectorOpen: false, + inspectorTab: "threads", + userExplicitlyOpenedInspector: false, + shortcutOverlayOpen: false, + + // Agent review + agentReviewResult: null, + + // Per-PR state + reviewedFiles: [], + reviewBody: "", + + // ── Actions ───────────────────────────────────────────────────── + + selectPr: (prNumber) => + set({ + selectedPrNumber: prNumber, + selectedFilePath: null, + selectedThreadId: null, + inspectorOpen: true, + }), + + selectFile: (path) => set({ selectedFilePath: path }), + + selectThread: (threadId) => set({ selectedThreadId: threadId }), + + setPullRequestState: (state) => set({ pullRequestState: state }), + + setSearchQuery: (query) => set({ searchQuery: query }), + + setWorkflowId: (id) => set({ workflowId: id }), + + toggleLeftRail: () => + set((state) => { + const next = !state.leftRailCollapsed; + writeLocalStorage("okcode:pr-review:left-rail-collapsed", next); + return { leftRailCollapsed: next }; + }), + + setLeftRailCollapsed: (collapsed) => { + writeLocalStorage("okcode:pr-review:left-rail-collapsed", collapsed); + set({ leftRailCollapsed: collapsed }); + }, + + toggleInspector: () => + set((state) => { + const next = !state.inspectorCollapsed; + if (!next) { + return { + inspectorCollapsed: next, + userExplicitlyOpenedInspector: true, + }; + } + writeLocalStorage("okcode:pr-review:inspector-collapsed", next); + return { inspectorCollapsed: next }; + }), + + setInspectorCollapsed: (collapsed) => { + writeLocalStorage("okcode:pr-review:inspector-collapsed", collapsed); + set({ inspectorCollapsed: collapsed }); + }, + + setActionRailExpanded: (expanded) => set({ actionRailExpanded: expanded }), + + setConflictDrawerOpen: (open) => set({ conflictDrawerOpen: open }), + + setInspectorOpen: (open) => set({ inspectorOpen: open }), + + setInspectorTab: (tab) => set({ inspectorTab: tab }), + + expandInspectorToTab: (tab) => + set({ + inspectorCollapsed: false, + inspectorTab: tab, + userExplicitlyOpenedInspector: true, + }), + + setShortcutOverlayOpen: (open) => set({ shortcutOverlayOpen: open }), + + setAgentReviewResult: (result) => + set({ + agentReviewResult: result, + ...(result?.status === "complete" ? { inspectorTab: "ai" } : {}), + }), + + setReviewedFiles: (files) => set({ reviewedFiles: files }), + + toggleFileReviewed: (path) => + set((state) => { + const fileSet = new Set(state.reviewedFiles); + if (fileSet.has(path)) fileSet.delete(path); + else fileSet.add(path); + return { reviewedFiles: [...fileSet] }; + }), + + setReviewBody: (body) => set({ reviewBody: body }), + + resetForNewPr: () => + set({ + selectedFilePath: null, + selectedThreadId: null, + agentReviewResult: null, + reviewedFiles: [], + reviewBody: "", + }), +})); diff --git a/packages/contracts/src/prReview.ts b/packages/contracts/src/prReview.ts index c169dba9..ebf531b2 100644 --- a/packages/contracts/src/prReview.ts +++ b/packages/contracts/src/prReview.ts @@ -409,3 +409,75 @@ export const PrReviewRepoConfigUpdatedPayload = Schema.Struct({ relativePaths: Schema.Array(TrimmedNonEmptyString), }); export type PrReviewRepoConfigUpdatedPayload = typeof PrReviewRepoConfigUpdatedPayload.Type; + +// ── Agent Review ──────────────────────────────────────────────────── + +export const PrAgentReviewStatus = Schema.Literals([ + "idle", + "queued", + "running", + "complete", + "failed", +]); +export type PrAgentReviewStatus = typeof PrAgentReviewStatus.Type; + +export const PrAgentFindingSeverity = Schema.Literals(["info", "warning", "critical"]); +export type PrAgentFindingSeverity = typeof PrAgentFindingSeverity.Type; + +export const PrAgentFindingCategory = Schema.Literals([ + "security", + "correctness", + "style", + "test-coverage", + "performance", +]); +export type PrAgentFindingCategory = typeof PrAgentFindingCategory.Type; + +export const PrAgentFinding = Schema.Struct({ + id: TrimmedNonEmptyString, + severity: PrAgentFindingSeverity, + category: PrAgentFindingCategory, + path: Schema.NullOr(TrimmedNonEmptyString), + line: Schema.NullOr(PositiveInt), + title: TrimmedNonEmptyString, + detail: Schema.String, +}); +export type PrAgentFinding = typeof PrAgentFinding.Type; + +export const PrAgentRiskTier = Schema.Literals(["low", "medium", "high"]); +export type PrAgentRiskTier = typeof PrAgentRiskTier.Type; + +export const PrAgentRiskAssessment = Schema.Struct({ + tier: PrAgentRiskTier, + rationale: Schema.String, +}); +export type PrAgentRiskAssessment = typeof PrAgentRiskAssessment.Type; + +export const PrAgentReviewResult = Schema.Struct({ + status: PrAgentReviewStatus, + summary: Schema.NullOr(Schema.String), + riskAssessment: Schema.NullOr(PrAgentRiskAssessment), + suggestedFocus: Schema.Array(Schema.String), + findings: Schema.Array(PrAgentFinding), + startedAt: Schema.NullOr(Schema.String), + completedAt: Schema.NullOr(Schema.String), +}); +export type PrAgentReviewResult = typeof PrAgentReviewResult.Type; + +export const PrAgentReviewStartInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + prNumber: PositiveInt, + workflowId: Schema.optional(TrimmedNonEmptyString), +}); +export type PrAgentReviewStartInput = typeof PrAgentReviewStartInput.Type; + +export const PrAgentReviewStatusInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + prNumber: PositiveInt, +}); +export type PrAgentReviewStatusInput = typeof PrAgentReviewStatusInput.Type; + +export const PrAgentReviewStartResult = Schema.Struct({ + reviewId: TrimmedNonEmptyString, +}); +export type PrAgentReviewStartResult = typeof PrAgentReviewStartResult.Type;