docs: add github integration (pr watcher) (#262)

This commit is contained in:
Carlos Florêncio
2026-02-23 00:16:37 +00:00
committed by GitHub
parent 66ba91ca3f
commit dff0b49c51
100 changed files with 9802 additions and 81 deletions
@@ -0,0 +1,166 @@
"use client";
import { useState, useCallback } from "react";
import { IconBrandGithub, IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Separator } from "@kandev/ui/separator";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { GitHubStatusCard } from "./github-status";
import { ReviewWatchTable } from "./review-watch-table";
import { ReviewWatchDialog } from "./review-watch-dialog";
import { PRStatsPanel } from "./pr-stats";
import { useReviewWatches } from "@/hooks/domains/github/use-review-watches";
import type { ReviewWatch } from "@/lib/types/github";
type GitHubSettingsProps = {
workspaceId: string;
};
function useWatchActions(workspaceId: string | null) {
const { items: watches, create, update, remove, trigger } = useReviewWatches(workspaceId);
const { toast } = useToast();
const handleDelete = useCallback(
async (id: string) => {
try {
await remove(id);
toast({ description: "Review watch deleted", variant: "success" });
} catch {
toast({ description: "Failed to delete review watch", variant: "error" });
}
},
[remove, toast],
);
const handleTrigger = useCallback(
async (id: string) => {
try {
const result = await trigger(id);
const count = result?.new_prs_found ?? 0;
if (count > 0) {
toast({
description: `Found ${count} new PR${count > 1 ? "s" : ""}`,
variant: "success",
});
} else {
toast({ description: "No new PRs found" });
}
} catch {
toast({ description: "Failed to check for PRs", variant: "error" });
}
},
[trigger, toast],
);
const handleToggleEnabled = useCallback(
async (watch: ReviewWatch) => {
try {
await update(watch.id, { enabled: !watch.enabled });
toast({
description: watch.enabled ? "Watch paused" : "Watch enabled",
variant: "success",
});
} catch {
toast({ description: "Failed to update watch", variant: "error" });
}
},
[update, toast],
);
return { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled };
}
export function GitHubSettings({ workspaceId }: GitHubSettingsProps) {
const { watches, create, update, handleDelete, handleTrigger, handleToggleEnabled } =
useWatchActions(workspaceId);
const { toast } = useToast();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingWatch, setEditingWatch] = useState<ReviewWatch | null>(null);
const handleEdit = useCallback((watch: ReviewWatch) => {
setEditingWatch(watch);
setDialogOpen(true);
}, []);
return (
<TooltipProvider>
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<IconBrandGithub className="h-6 w-6" />
GitHub Integration
</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure GitHub PR monitoring and review queue automation.
</p>
</div>
<Separator />
<SettingsSection title="Connection Status" description="GitHub authentication status">
<Card>
<CardContent className="py-3">
<GitHubStatusCard />
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Review Watches"
description="Automatically create tasks for PRs that need your review."
action={
<Button
size="sm"
onClick={() => {
setEditingWatch(null);
setDialogOpen(true);
}}
className="cursor-pointer"
>
<IconPlus className="h-4 w-4 mr-1" />
Add Watch
</Button>
}
>
<Card>
<CardContent className="p-0">
<ReviewWatchTable
watches={watches}
onEdit={handleEdit}
onDelete={handleDelete}
onTrigger={handleTrigger}
onToggleEnabled={handleToggleEnabled}
/>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="PR Analytics"
description="Pull request activity for this workspace."
>
<PRStatsPanel workspaceId={workspaceId} />
</SettingsSection>
<ReviewWatchDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
watch={editingWatch}
workspaceId={workspaceId}
onCreate={async (req) => {
await create(req);
toast({ description: "Review watch created", variant: "success" });
}}
onUpdate={async (id, req) => {
await update(id, req);
toast({ description: "Review watch updated", variant: "success" });
}}
/>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,44 @@
"use client";
import { IconCheck, IconX } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Spinner } from "@kandev/ui/spinner";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
export function GitHubStatusCard() {
const { status, loaded, loading } = useGitHubStatus();
if (loading || !loaded) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="h-4 w-4" />
Checking GitHub connection...
</div>
);
}
if (!status || !status.authenticated) {
return (
<div className="flex items-center gap-2">
<IconX className="h-4 w-4 text-red-500" />
<span className="text-sm">Not connected to GitHub</span>
<span className="text-xs text-muted-foreground">
Run <code className="bg-muted px-1 rounded">gh auth login</code> or add a GITHUB_TOKEN
secret
</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
<IconCheck className="h-4 w-4 text-green-500" />
<span className="text-sm">
Connected as <strong>{status.username}</strong>
</span>
<Badge variant="secondary" className="text-xs">
{status.auth_method === "gh_cli" ? "gh CLI" : "PAT"}
</Badge>
</div>
);
}
@@ -0,0 +1,128 @@
import { IconCheck, IconX, IconClock } from "@tabler/icons-react";
import type { CheckRun } from "@/lib/types/github";
import { CollapsibleSection, AddToContextButton, formatDuration, formatElapsed } from "./pr-shared";
function CheckStatusIcon({ check }: { check: CheckRun }) {
const value = check.conclusion || check.status;
if (value === "success") return <IconCheck className="h-3.5 w-3.5 text-green-500 shrink-0" />;
if (value === "failure" || value === "timed_out")
return <IconX className="h-3.5 w-3.5 text-red-500 shrink-0" />;
if (value === "in_progress" || value === "queued")
return <IconClock className="h-3.5 w-3.5 text-yellow-500 shrink-0 animate-pulse" />;
return <IconClock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />;
}
function conclusionLabel(check: CheckRun): string | null {
const c = check.conclusion;
if (!c || c === "success" || c === "failure") return c || null;
const labels: Record<string, string> = {
timed_out: "timed out",
cancelled: "cancelled",
action_required: "action required",
skipped: "skipped",
neutral: "neutral",
stale: "stale",
};
return labels[c] ?? c;
}
function checkDurationText(check: CheckRun): string | null {
if (check.started_at && check.completed_at)
return formatDuration(check.started_at, check.completed_at);
if (check.started_at && !check.completed_at) return `${formatElapsed(check.started_at)} running`;
return null;
}
function isFailedCheck(check: CheckRun): boolean {
return check.conclusion === "failure" || check.conclusion === "timed_out";
}
function buildCheckMessage(check: CheckRun): string {
const parts = [`CI check **${check.name}** failed (${check.conclusion}).`];
if (check.output) parts.push(check.output);
if (check.html_url) parts.push(`Check URL: ${check.html_url}`);
parts.push("Please investigate and fix this failing check.");
return parts.join("\n\n");
}
function buildAllFailedMessage(checks: CheckRun[]): string {
const failed = checks.filter(isFailedCheck);
const parts = [`### ${failed.length} CI Check${failed.length !== 1 ? "s" : ""} Failed`, ""];
for (const check of failed) {
parts.push(`**${check.name}** — ${check.conclusion}`);
if (check.output) parts.push(check.output);
if (check.html_url) parts.push(`URL: ${check.html_url}`);
parts.push("");
}
parts.push("Please investigate and fix these failing checks.");
return parts.join("\n");
}
function formatSectionSummary(checks: CheckRun[]): string {
const failed = checks.filter(isFailedCheck).length;
const passed = checks.filter((c) => c.conclusion === "success").length;
const pending = checks.length - failed - passed;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} failed`);
if (passed > 0) parts.push(`${passed} passed`);
if (pending > 0) parts.push(`${pending} pending`);
return parts.join(", ");
}
export function ChecksSection({
checks,
onAddAsContext,
}: {
checks: CheckRun[];
onAddAsContext: (message: string) => void;
}) {
const summary = checks.length > 0 ? ` \u2014 ${formatSectionSummary(checks)}` : "";
const hasFailed = checks.some(isFailedCheck);
return (
<CollapsibleSection
title={`CI Checks${summary}`}
count={checks.length}
defaultOpen
onAddAll={hasFailed ? () => onAddAsContext(buildAllFailedMessage(checks)) : undefined}
addAllLabel="Add all failed checks to chat context"
>
{checks.length === 0 && <p className="text-xs text-muted-foreground px-2 py-2">No checks</p>}
{checks.map((check) => {
const label = conclusionLabel(check);
const duration = checkDurationText(check);
return (
<div key={check.name} className="px-2.5 py-2 rounded-md border border-border bg-muted/30">
<div className="flex items-center gap-2 text-xs">
<CheckStatusIcon check={check} />
{check.html_url ? (
<a
href={check.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium truncate hover:underline cursor-pointer"
>
{check.name}
</a>
) : (
<span className="font-medium truncate">{check.name}</span>
)}
{isFailedCheck(check) && (
<div className="ml-auto shrink-0">
<AddToContextButton onClick={() => onAddAsContext(buildCheckMessage(check))} />
</div>
)}
</div>
{(label || duration) && (
<div className="flex items-center gap-1 pl-5.5 mt-0.5 text-[10px] text-muted-foreground">
{label && <span>{label}</span>}
{label && duration && <span>&middot;</span>}
{duration && <span>{duration}</span>}
</div>
)}
</div>
);
})}
</CollapsibleSection>
);
}
@@ -0,0 +1,180 @@
import { useMemo } from "react";
import type { PRComment } from "@/lib/types/github";
import {
CollapsibleSection,
AddToContextButton,
AuthorAvatar,
AuthorLink,
formatTimeAgo,
} from "./pr-shared";
function buildCommentMessage(comment: PRComment, prUrl: string): string {
const location = comment.path
? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\``
: "";
const header = location
? `Comment from **${comment.author}** on ${location}:`
: `Comment from **${comment.author}**:`;
return [header, comment.body, `PR: ${prUrl}`, "Please address this comment."].join("\n\n");
}
function buildThreadMessage(thread: CommentThread, prUrl: string): string {
const parts = ["### Comment Thread", ""];
for (const c of [thread.root, ...thread.replies]) {
const loc = c.path ? ` on \`${c.path}${c.line > 0 ? `:${c.line}` : ""}\`` : "";
parts.push(`**${c.author}**${loc}:`);
parts.push(c.body);
parts.push("");
}
parts.push(`PR: ${prUrl}`);
parts.push("Please address this comment thread.");
return parts.join("\n");
}
function buildAllCommentsMessage(comments: PRComment[], prUrl: string): string {
const parts = ["### All PR Comments", ""];
for (const c of comments) {
const loc = c.path ? ` on \`${c.path}${c.line > 0 ? `:${c.line}` : ""}\`` : "";
parts.push(`**${c.author}**${loc}:`);
parts.push(c.body);
parts.push("");
}
parts.push(`PR: ${prUrl}`);
parts.push("Please address the comments above.");
return parts.join("\n");
}
type CommentThread = {
root: PRComment;
replies: PRComment[];
};
function buildThreads(comments: PRComment[]): CommentThread[] {
const byId = new Map<number, PRComment>();
for (const c of comments) byId.set(c.id, c);
const roots: PRComment[] = [];
const repliesByParent = new Map<number, PRComment[]>();
for (const c of comments) {
if (c.in_reply_to === null || c.in_reply_to === 0 || !byId.has(c.in_reply_to)) {
roots.push(c);
} else {
const existing = repliesByParent.get(c.in_reply_to) ?? [];
existing.push(c);
repliesByParent.set(c.in_reply_to, existing);
}
}
return roots.map((root) => ({
root,
replies: repliesByParent.get(root.id) ?? [],
}));
}
function CommentItem({
comment,
prUrl,
onAddAsContext,
isReply,
}: {
comment: PRComment;
prUrl: string;
onAddAsContext: (message: string) => void;
isReply?: boolean;
}) {
return (
<div className={isReply ? "ml-4 pl-2.5 border-l-2 border-border" : ""}>
<div className="px-2.5 py-2 rounded-md border border-border bg-muted/30 space-y-1">
<div className="flex items-center gap-2">
<AuthorAvatar src={comment.author_avatar} author={comment.author} />
<AuthorLink author={comment.author} />
{comment.path && (
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px]">
{comment.path}
{comment.line > 0 && `:${comment.line}`}
</span>
)}
{!comment.path && !isReply && (
<span className="text-[10px] text-muted-foreground">(general)</span>
)}
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{formatTimeAgo(comment.created_at)}
</span>
<AddToContextButton onClick={() => onAddAsContext(buildCommentMessage(comment, prUrl))} />
</div>
<p className="text-xs text-muted-foreground pl-7 line-clamp-4">{comment.body}</p>
</div>
</div>
);
}
function ThreadBlock({
thread,
prUrl,
onAddAsContext,
}: {
thread: CommentThread;
prUrl: string;
onAddAsContext: (message: string) => void;
}) {
const hasReplies = thread.replies.length > 0;
return (
<div className="space-y-1.5">
<div className="flex items-start gap-1">
<div className="flex-1">
<CommentItem comment={thread.root} prUrl={prUrl} onAddAsContext={onAddAsContext} />
</div>
{hasReplies && (
<AddToContextButton
onClick={() => onAddAsContext(buildThreadMessage(thread, prUrl))}
tooltip="Add whole thread to chat context"
/>
)}
</div>
{thread.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
prUrl={prUrl}
onAddAsContext={onAddAsContext}
isReply
/>
))}
</div>
);
}
export function CommentsSection({
comments,
prUrl,
onAddAsContext,
}: {
comments: PRComment[];
prUrl: string;
onAddAsContext: (message: string) => void;
}) {
const threads = useMemo(() => buildThreads(comments), [comments]);
return (
<CollapsibleSection
title="Comments"
count={comments.length}
defaultOpen
onAddAll={() => onAddAsContext(buildAllCommentsMessage(comments, prUrl))}
addAllLabel="Add all comments to chat context"
>
{comments.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-2">No comments yet</p>
)}
{threads.map((thread) => (
<ThreadBlock
key={thread.root.id}
thread={thread}
prUrl={prUrl}
onAddAsContext={onAddAsContext}
/>
))}
</CollapsibleSection>
);
}
@@ -0,0 +1,290 @@
"use client";
import { useCallback, useEffect } from "react";
import type { IDockviewPanelProps } from "dockview-react";
import {
IconRefresh,
IconPlus,
IconMinus,
IconAlertTriangle,
IconGitMerge,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Separator } from "@kandev/ui/separator";
import { ScrollArea } from "@kandev/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Spinner } from "@kandev/ui/spinner";
import { useAppStore } from "@/components/state-provider";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { prPanelLabel } from "@/components/github/pr-utils";
import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback";
import { useCommentsStore } from "@/lib/state/slices/comments";
import type { PRFeedbackComment } from "@/lib/state/slices/comments";
import { useToast } from "@/components/toast-provider";
import type { TaskPR, PRFeedback } from "@/lib/types/github";
import { formatTimeAgo, AuthorLink } from "./pr-shared";
import { ReviewStateBadge } from "./pr-reviews-section";
import { ChecksSection } from "./pr-checks-section";
import { ReviewsSection } from "./pr-reviews-section";
import { CommentsSection } from "./pr-comments-section";
// --- Dockview panel wrapper ---
export function PRDetailPanelComponent(props: IDockviewPanelProps) {
const pr = useActiveTaskPR();
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
useEffect(() => {
const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request";
if (props.api.title !== title) props.api.setTitle(title);
}, [pr, props.api]);
if (!pr || !sessionId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No pull request linked to this session.
</div>
);
}
return <PRDetailContent taskPR={pr} sessionId={sessionId} />;
}
// --- Add PR feedback as chat context ---
function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) {
const { toast } = useToast();
const addComment = useCommentsStore((s) => s.addComment);
const addAsContext = useCallback(
(feedbackType: PRFeedbackComment["feedbackType"], content: string) => {
const comment: PRFeedbackComment = {
id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
sessionId,
text: content,
createdAt: new Date().toISOString(),
status: "pending",
source: "pr-feedback",
prNumber,
feedbackType,
content,
};
addComment(comment);
toast({ description: "Added to chat context" });
},
[sessionId, prNumber, addComment, toast],
);
return { addAsContext };
}
// --- Main content ---
function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) {
const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number);
const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number);
return (
<div className="flex flex-col h-full">
<PRHeader taskPR={taskPR} feedback={feedback} loading={loading} onRefresh={refresh} />
<Separator />
<ScrollArea className="flex-1 overflow-hidden">
<div className="p-3 space-y-1">
{loading && !feedback && (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
)}
{feedback && (
<>
<ReviewsSection
reviews={feedback.reviews}
prUrl={taskPR.pr_url}
reviewState={taskPR.review_state}
reviewCount={taskPR.review_count}
pendingReviewCount={taskPR.pending_review_count}
onAddAsContext={(msg) => addAsContext("review", msg)}
/>
<ChecksSection
checks={feedback.checks}
onAddAsContext={(msg) => addAsContext("check", msg)}
/>
<CommentsSection
comments={feedback.comments}
prUrl={taskPR.pr_url}
onAddAsContext={(msg) => addAsContext("comment", msg)}
/>
</>
)}
</div>
</ScrollArea>
{taskPR.last_synced_at && (
<>
<Separator />
<div className="px-3 py-2 text-[10px] text-muted-foreground text-center">
Last synced {formatTimeAgo(taskPR.last_synced_at)}
</div>
</>
)}
</div>
);
}
// --- Header ---
function StateBadge({ state }: { state: string }) {
const styles: Record<string, string> = {
open: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
merged: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
closed: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
};
return (
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 ${styles[state] ?? ""}`}>
{state}
</Badge>
);
}
function HeaderTitleRow({
taskPR,
loading,
onRefresh,
}: {
taskPR: TaskPR;
loading: boolean;
onRefresh: () => void;
}) {
return (
<div className="flex items-start justify-between gap-2">
<a
href={taskPR.pr_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline truncate cursor-pointer min-w-0 flex-1"
>
{taskPR.pr_title}
</a>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0 text-muted-foreground hover:text-foreground"
onClick={onRefresh}
disabled={loading}
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</div>
);
}
function HeaderDateLine({ taskPR }: { taskPR: TaskPR }) {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-0.5">
by <AuthorLink author={taskPR.author_login} />
</span>
<span>&middot;</span>
<span>opened {formatTimeAgo(taskPR.created_at)}</span>
{taskPR.merged_at && (
<>
<span>&middot;</span>
<span className="flex items-center gap-0.5">
<IconGitMerge className="h-3 w-3 text-purple-500" />
merged {formatTimeAgo(taskPR.merged_at)}
</span>
</>
)}
{taskPR.closed_at && !taskPR.merged_at && (
<>
<span>&middot;</span>
<span>closed {formatTimeAgo(taskPR.closed_at)}</span>
</>
)}
</div>
);
}
function HeaderStatsLine({ taskPR }: { taskPR: TaskPR }) {
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<IconPlus className="h-3 w-3 text-green-500" />
{taskPR.additions}
</span>
<span className="flex items-center gap-1">
<IconMinus className="h-3 w-3 text-red-500" />
{taskPR.deletions}
</span>
<span>&middot;</span>
<span>
{taskPR.review_count} review{taskPR.review_count !== 1 ? "s" : ""}
{taskPR.pending_review_count > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">
{" "}
({taskPR.pending_review_count} pending)
</span>
)}
</span>
<span>&middot;</span>
<span>
{taskPR.comment_count} comment{taskPR.comment_count !== 1 ? "s" : ""}
</span>
{taskPR.review_state && <ReviewStateBadge state={taskPR.review_state} />}
</div>
);
}
function PRHeader({
taskPR,
feedback,
loading,
onRefresh,
}: {
taskPR: TaskPR;
feedback: PRFeedback | null;
loading: boolean;
onRefresh: () => void;
}) {
const isDraft = feedback?.pr.draft ?? false;
const isMergeable = feedback?.pr.mergeable ?? true;
const showWarnings = isDraft || (!isMergeable && taskPR.state === "open");
return (
<div className="p-3 space-y-2">
<HeaderTitleRow taskPR={taskPR} loading={loading} onRefresh={onRefresh} />
<div className="flex items-center gap-1.5 flex-wrap">
<StateBadge state={taskPR.state} />
<span className="text-xs text-muted-foreground">#{taskPR.pr_number}</span>
<span className="text-xs text-muted-foreground">
{taskPR.head_branch} &rarr; {taskPR.base_branch}
</span>
</div>
{showWarnings && (
<div className="flex items-center gap-1.5 flex-wrap">
{isDraft && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
Draft
</Badge>
)}
{!isMergeable && taskPR.state === "open" && (
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400">
<IconAlertTriangle className="h-3 w-3" />
Not mergeable
</span>
)}
</div>
)}
<HeaderDateLine taskPR={taskPR} />
<HeaderStatsLine taskPR={taskPR} />
</div>
);
}
@@ -0,0 +1,144 @@
import { IconCheck, IconX, IconClock } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import type { PRReview } from "@/lib/types/github";
import {
CollapsibleSection,
AddToContextButton,
AuthorAvatar,
AuthorLink,
formatTimeAgo,
} from "./pr-shared";
function ReviewStateIcon({ state }: { state: string }) {
if (state === "APPROVED") return <IconCheck className="h-3.5 w-3.5 text-green-500 shrink-0" />;
if (state === "CHANGES_REQUESTED") return <IconX className="h-3.5 w-3.5 text-red-500 shrink-0" />;
return <IconClock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />;
}
function reviewStateLabel(state: string): string {
const labels: Record<string, string> = {
APPROVED: "Approved",
CHANGES_REQUESTED: "Changes Requested",
COMMENTED: "Commented",
PENDING: "Pending",
DISMISSED: "Dismissed",
};
return labels[state] ?? state;
}
export function ReviewStateBadge({ state }: { state: string }) {
if (!state) return null;
const styles: Record<string, string> = {
approved: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
changes_requested: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
};
const labelMap: Record<string, string> = {
approved: "Approved",
changes_requested: "Changes requested",
pending: "Review pending",
};
return (
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 ${styles[state] ?? ""}`}>
{labelMap[state] ?? state}
</Badge>
);
}
function buildReviewMessage(review: PRReview, prUrl: string): string {
const parts = [`Review from **${review.author}** (${review.state}):`];
if (review.body) parts.push(review.body);
parts.push(`PR: ${prUrl}`);
parts.push("Please address this review feedback.");
return parts.join("\n\n");
}
function buildAllReviewsMessage(reviews: PRReview[], prUrl: string): string {
const parts = ["### All PR Reviews", ""];
for (const r of reviews) {
parts.push(`**${r.author}** — ${reviewStateLabel(r.state)}`);
if (r.body) parts.push(r.body);
parts.push("");
}
parts.push(`PR: ${prUrl}`);
parts.push("Please address the review feedback above.");
return parts.join("\n");
}
function formatReviewSummary(reviews: PRReview[]): string {
const approved = reviews.filter((r) => r.state === "APPROVED").length;
const changes = reviews.filter((r) => r.state === "CHANGES_REQUESTED").length;
const parts: string[] = [];
if (approved > 0) parts.push(`${approved} approved`);
if (changes > 0) parts.push(`${changes} changes requested`);
const other = reviews.length - approved - changes;
if (other > 0) parts.push(`${other} other`);
return parts.join(", ");
}
export function ReviewsSection({
reviews,
prUrl,
reviewState,
reviewCount,
pendingReviewCount,
onAddAsContext,
}: {
reviews: PRReview[];
prUrl: string;
reviewState: string;
reviewCount: number;
pendingReviewCount: number;
onAddAsContext: (message: string) => void;
}) {
const summary = reviews.length > 0 ? ` \u2014 ${formatReviewSummary(reviews)}` : "";
const pendingText = pendingReviewCount > 0 ? ` (${pendingReviewCount} pending)` : "";
const subtitle = reviewState ? (
<div className="text-[10px] text-muted-foreground px-2 pb-1">
Overall: <ReviewStateBadge state={reviewState} />
{pendingText && <span className="text-yellow-600 dark:text-yellow-400">{pendingText}</span>}
</div>
) : null;
return (
<CollapsibleSection
title={`Reviews${summary}`}
count={reviewCount}
defaultOpen
subtitle={subtitle}
onAddAll={() => onAddAsContext(buildAllReviewsMessage(reviews, prUrl))}
addAllLabel="Add all reviews to chat context"
>
{reviews.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-2">No reviews yet</p>
)}
{reviews.map((review) => (
<div
key={review.id}
className="px-2.5 py-2 rounded-md border border-border bg-muted/30 space-y-1"
>
<div className="flex items-center gap-2">
<AuthorAvatar src={review.author_avatar} author={review.author} />
<ReviewStateIcon state={review.state} />
<AuthorLink author={review.author} />
<span className="text-[10px] text-muted-foreground truncate">
{reviewStateLabel(review.state)}
</span>
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{formatTimeAgo(review.created_at)}
</span>
{(review.state === "CHANGES_REQUESTED" || review.body) && (
<AddToContextButton
onClick={() => onAddAsContext(buildReviewMessage(review, prUrl))}
/>
)}
</div>
{review.body && (
<p className="text-xs text-muted-foreground pl-7 line-clamp-4">{review.body}</p>
)}
</div>
))}
</CollapsibleSection>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
export function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function formatDuration(startedAt: string, completedAt: string): string {
return formatMs(new Date(completedAt).getTime() - new Date(startedAt).getTime());
}
export function formatElapsed(startedAt: string): string {
return formatMs(Date.now() - new Date(startedAt).getTime());
}
export function AuthorAvatar({ src, author }: { src: string; author: string }) {
const inner = src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={author} className="h-5 w-5 rounded-full shrink-0" />
) : (
<div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium text-muted-foreground">
{author[0]?.toUpperCase() ?? "?"}
</div>
);
return (
<a
href={`https://github.com/${author}`}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 cursor-pointer"
>
{inner}
</a>
);
}
export function AuthorLink({ author }: { author: string }) {
return (
<a
href={`https://github.com/${author}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium hover:underline cursor-pointer"
>
{author}
</a>
);
}
export function CollapsibleSection({
title,
count,
defaultOpen,
subtitle,
onAddAll,
addAllLabel,
children,
}: {
title: string;
count: number;
defaultOpen?: boolean;
subtitle?: React.ReactNode;
onAddAll?: () => void;
addAllLabel?: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen ?? true);
return (
<div>
<div className="flex items-center">
<button
type="button"
className="flex items-center gap-1.5 flex-1 py-2 text-xs font-semibold text-foreground/80 hover:text-foreground cursor-pointer"
onClick={() => setOpen(!open)}
>
{open ? (
<IconChevronDown className="h-3.5 w-3.5" />
) : (
<IconChevronRight className="h-3.5 w-3.5" />
)}
{title}
<span className="text-muted-foreground font-normal">({count})</span>
</button>
{onAddAll && count > 0 && open && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 cursor-pointer text-[10px] text-muted-foreground hover:text-foreground gap-1"
onClick={onAddAll}
>
<IconMessagePlus className="h-3 w-3" />
Add all
</Button>
</TooltipTrigger>
<TooltipContent>{addAllLabel ?? "Add all to chat context"}</TooltipContent>
</Tooltip>
)}
</div>
{subtitle && open && <div className="pb-1">{subtitle}</div>}
{open && <div className="space-y-2 pb-3">{children}</div>}
</div>
);
}
export function AddToContextButton({
onClick,
tooltip,
}: {
onClick: () => void;
tooltip?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<IconMessagePlus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip ?? "Add to chat context"}</TooltipContent>
</Tooltip>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useReducer } from "react";
import {
IconGitPullRequest,
IconEye,
IconMessage,
IconCheck,
IconClock,
} from "@tabler/icons-react";
import { Card, CardContent } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { fetchGitHubStats } from "@/lib/api/domains/github-api";
import type { PRStats } from "@/lib/types/github";
type StatCardProps = {
icon: React.ReactNode;
label: string;
value: string | number;
subtext?: string;
};
function StatCard({ icon, label, value, subtext }: StatCardProps) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="text-muted-foreground">{icon}</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
{subtext && <p className="text-[10px] text-muted-foreground mt-0.5">{subtext}</p>}
</div>
</div>
</CardContent>
</Card>
);
}
function formatHours(hours: number): string {
if (hours < 1) return "< 1h";
if (hours < 24) return `${Math.round(hours)}h`;
const days = Math.round(hours / 24);
return `${days}d`;
}
function formatPercent(rate: number): string {
return `${Math.round(rate * 100)}%`;
}
type StatsState = { stats: PRStats | null; loading: boolean };
type StatsAction = { type: "fetch" } | { type: "done"; stats: PRStats | null };
function statsReducer(state: StatsState, action: StatsAction): StatsState {
switch (action.type) {
case "fetch":
return { ...state, loading: true };
case "done":
return { stats: action.stats, loading: false };
}
}
function usePRStats(workspaceId: string | null) {
const [state, dispatch] = useReducer(statsReducer, { stats: null, loading: true });
useEffect(() => {
let cancelled = false;
dispatch({ type: "fetch" });
fetchGitHubStats(workspaceId ? { workspace_id: workspaceId } : {}, { cache: "no-store" })
.then((data) => {
if (!cancelled) dispatch({ type: "done", stats: data });
})
.catch(() => {
if (!cancelled) dispatch({ type: "done", stats: null });
});
return () => {
cancelled = true;
};
}, [workspaceId]);
return state;
}
function StatsGrid({ stats }: { stats: PRStats }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
icon={<IconGitPullRequest className="h-5 w-5" />}
label="PRs Created"
value={stats.total_prs_created}
/>
<StatCard
icon={<IconEye className="h-5 w-5" />}
label="PRs Reviewed"
value={stats.total_prs_reviewed}
/>
<StatCard
icon={<IconMessage className="h-5 w-5" />}
label="Comments"
value={stats.total_comments}
/>
<StatCard
icon={<IconCheck className="h-5 w-5" />}
label="CI Pass Rate"
value={formatPercent(stats.ci_pass_rate)}
/>
<StatCard
icon={<IconCheck className="h-5 w-5" />}
label="Approval Rate"
value={formatPercent(stats.approval_rate)}
/>
<StatCard
icon={<IconClock className="h-5 w-5" />}
label="Avg Time to Merge"
value={formatHours(stats.avg_time_to_merge_hours)}
/>
</div>
);
}
function PRsByDayChart({ prsByDay }: { prsByDay: PRStats["prs_by_day"] }) {
if (!prsByDay || prsByDay.length === 0) return null;
const maxCount = Math.max(...prsByDay.map((d) => d.count), 1);
return (
<Card>
<CardContent className="p-4">
<h4 className="text-sm font-medium mb-3">PRs by Day</h4>
<div className="flex items-end gap-1 h-24">
{prsByDay.map((day) => {
const height = Math.max((day.count / maxCount) * 100, 4);
return (
<div
key={day.date}
className="flex-1 bg-primary/20 hover:bg-primary/40 rounded-t transition-colors"
style={{ height: `${height}%` }}
title={`${day.date}: ${day.count} PRs`}
/>
);
})}
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-muted-foreground">{prsByDay[0]?.date}</span>
<span className="text-[10px] text-muted-foreground">
{prsByDay[prsByDay.length - 1]?.date}
</span>
</div>
</CardContent>
</Card>
);
}
export function PRStatsPanel({ workspaceId }: { workspaceId: string | null }) {
const { stats, loading } = usePRStats(workspaceId);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
);
}
if (!stats) {
return (
<p className="text-sm text-muted-foreground text-center py-4">No stats available yet.</p>
);
}
return (
<div className="space-y-4">
<StatsGrid stats={stats} />
<PRsByDayChart prsByDay={stats.prs_by_day} />
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import { IconGitPullRequest } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
import type { TaskPR } from "@/lib/types/github";
function getPRStatusColor(pr: TaskPR): string {
if (pr.state === "merged") return "text-purple-500";
if (pr.state === "closed") return "text-red-500";
if (pr.review_state === "changes_requested" || pr.checks_state === "failure") {
return "text-red-500";
}
if (pr.review_state === "approved" && pr.checks_state === "success") {
return "text-green-500";
}
if (pr.checks_state === "pending" || pr.review_state === "pending") {
return "text-yellow-500";
}
return "text-muted-foreground";
}
function getPRTooltip(pr: TaskPR): string {
const parts = [`PR #${pr.pr_number}: ${pr.pr_title}`];
if (pr.state !== "open") parts.push(`State: ${pr.state}`);
if (pr.review_state) parts.push(`Review: ${pr.review_state}`);
if (pr.checks_state) parts.push(`CI: ${pr.checks_state}`);
return parts.join(" | ");
}
export function PRTaskIcon({ taskId }: { taskId: string }) {
const pr = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null);
if (!pr) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex items-center", getPRStatusColor(pr))}>
<IconGitPullRequest className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent>{getPRTooltip(pr)}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,57 @@
"use client";
import { memo } from "react";
import { IconGitPullRequest, IconCheck, IconX, IconClock } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr";
import { useAppStore } from "@/components/state-provider";
import type { TaskPR } from "@/lib/types/github";
function PRStatusIcon({ pr }: { pr: TaskPR }) {
if (pr.checks_state === "failure" || pr.review_state === "changes_requested") {
return <IconX className="h-3 w-3 text-red-500" />;
}
if (pr.checks_state === "success" && pr.review_state === "approved") {
return <IconCheck className="h-3 w-3 text-green-500" />;
}
if (pr.checks_state === "pending" || pr.review_state === "pending") {
return <IconClock className="h-3 w-3 text-yellow-500" />;
}
if (pr.state === "merged") {
return <IconCheck className="h-3 w-3 text-purple-500" />;
}
if (pr.state === "closed") {
return <IconX className="h-3 w-3 text-muted-foreground" />;
}
return null;
}
export const PRTopbarButton = memo(function PRTopbarButton() {
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
// useTaskPR fetches if not in store; useActiveTaskPR just reads
useTaskPR(activeTaskId);
const pr = useActiveTaskPR();
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
if (!pr) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="cursor-pointer gap-1.5 px-2"
onClick={addPRPanel}
>
<IconGitPullRequest className="h-4 w-4" />
<span className="text-xs font-medium">#{pr.pr_number}</span>
<PRStatusIcon pr={pr} />
</Button>
</TooltipTrigger>
<TooltipContent>{pr.pr_title}</TooltipContent>
</Tooltip>
);
});
+4
View File
@@ -0,0 +1,4 @@
/** Shared label for the PR dockview panel tab and dropdown items. */
export function prPanelLabel(prNumber: number): string {
return `PR #${prNumber}`;
}
@@ -0,0 +1,67 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import { useAppStore } from "@/components/state-provider";
import { triggerAllReviewWatches, listReviewWatches } from "@/lib/api/domains/github-api";
export function RefreshReviewsButton() {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const hasWatches = useAppStore((state) => state.reviewWatches.items.length > 0);
const watchesLoaded = useAppStore((state) => state.reviewWatches.loaded);
const setReviewWatches = useAppStore((state) => state.setReviewWatches);
const fetchedRef = useRef(false);
useEffect(() => {
if (!workspaceId || watchesLoaded || fetchedRef.current) return;
fetchedRef.current = true;
listReviewWatches(workspaceId)
.then((r) => setReviewWatches(r?.watches ?? []))
.catch(() => {});
}, [workspaceId, watchesLoaded, setReviewWatches]);
const handleTrigger = useCallback(async () => {
if (!workspaceId || loading) return;
setLoading(true);
try {
const result = await triggerAllReviewWatches(workspaceId);
const count = result?.new_prs_found ?? 0;
if (count > 0) {
toast({
description: `Found ${count} new PR${count > 1 ? "s" : ""} to review`,
variant: "success",
});
} else {
toast({ description: "No new PRs to review" });
}
} catch {
toast({ description: "Failed to check for review PRs", variant: "error" });
} finally {
setLoading(false);
}
}, [workspaceId, loading, toast]);
if (!watchesLoaded || !hasWatches) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="cursor-pointer"
onClick={handleTrigger}
disabled={loading}
>
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Check for PRs to review</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,317 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { IconX, IconLoader2, IconPlus } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandItem,
CommandGroup,
} from "@kandev/ui/command";
import { Label } from "@kandev/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Switch } from "@kandev/ui/switch";
import { cn } from "@/lib/utils";
import { listUserOrgs, searchOrgRepos } from "@/lib/api/domains/github-api";
import type { RepoFilter, GitHubOrg, GitHubRepoInfo } from "@/lib/types/github";
type RepoFilterSelectorProps = {
allRepos: boolean;
selectedRepos: RepoFilter[];
onAllReposChange: (checked: boolean) => void;
onSelectedReposChange: (repos: RepoFilter[]) => void;
};
function useGitHubOrgs() {
const [orgs, setOrgs] = useState<GitHubOrg[]>([]);
const [loading, setLoading] = useState(true);
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
listUserOrgs()
.then((r) => setOrgs(r.orgs ?? []))
.catch(() => setOrgs([]))
.finally(() => setLoading(false));
}, []);
return { orgs, loading };
}
function useRepoSearch(org: string, query: string) {
const [searchState, setSearchState] = useState<{
results: GitHubRepoInfo[];
loading: boolean;
org: string;
}>({ results: [], loading: false, org: "" });
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
clearTimeout(timerRef.current);
if (!org) return;
timerRef.current = setTimeout(() => {
setSearchState((prev) => ({ ...prev, loading: true, org }));
searchOrgRepos(org, query || undefined)
.then((r) => setSearchState({ results: r.repos ?? [], loading: false, org }))
.catch(() => setSearchState({ results: [], loading: false, org }));
}, 300);
return () => clearTimeout(timerRef.current);
}, [org, query]);
// Clear results when org becomes empty (derived, not in effect)
const results = org ? searchState.results : [];
const loading = org ? searchState.loading : false;
return { results, loading };
}
function SelectedFilters({
repos,
onRemove,
disabled,
}: {
repos: RepoFilter[];
onRemove: (r: RepoFilter) => void;
disabled: boolean;
}) {
if (repos.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{repos.map((r) => {
const label = r.name === "" ? `${r.owner}/*` : `${r.owner}/${r.name}`;
return (
<Badge
key={label}
variant="secondary"
className={cn("text-xs gap-1 pr-1", disabled && "opacity-50")}
>
{label}
<button
type="button"
className="ml-0.5 hover:text-foreground cursor-pointer"
onClick={() => onRemove(r)}
disabled={disabled}
>
<IconX className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
);
}
function OrgBadges({
orgs,
loading,
selectedRepos,
disabled,
onToggleOrg,
}: {
orgs: GitHubOrg[];
loading: boolean;
selectedRepos: RepoFilter[];
disabled: boolean;
onToggleOrg: (login: string) => void;
}) {
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconLoader2 className="h-3 w-3 animate-spin" />
Loading organizations...
</div>
);
}
if (orgs.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{orgs.map((org) => {
const isSelected = selectedRepos.some((r) => r.owner === org.login && r.name === "");
return (
<Badge
key={org.login}
variant={isSelected ? "default" : "outline"}
className={cn(
"text-xs cursor-pointer select-none",
disabled && "opacity-50 pointer-events-none",
)}
onClick={() => onToggleOrg(org.login)}
>
{org.login}
</Badge>
);
})}
</div>
);
}
function RepoSearchCombobox({
disabled,
onAdd,
}: {
disabled: boolean;
onAdd: (owner: string, name: string) => void;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const slashIdx = value.indexOf("/");
const org = slashIdx > 0 ? value.slice(0, slashIdx) : "";
const query = slashIdx > 0 ? value.slice(slashIdx + 1) : "";
const { results, loading: searchLoading } = useRepoSearch(org, query);
const filteredResults = useMemo(() => results.slice(0, 10), [results]);
const handleSelect = useCallback(
(fullName: string) => {
const [owner, ...rest] = fullName.split("/");
const name = rest.join("/");
if (owner && name) {
onAdd(owner, name);
setValue("");
setOpen(false);
}
},
[onAdd],
);
return (
<Popover open={open && !disabled} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="cursor-pointer text-xs gap-1"
>
<IconPlus className="h-3 w-3" />
Add repository
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
<Command shouldFilter={false}>
<CommandInput value={value} onValueChange={setValue} placeholder="owner/repo" />
<CommandList>
{searchLoading && (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
<IconLoader2 className="h-3 w-3 animate-spin" />
Searching...
</div>
)}
{!searchLoading && org && filteredResults.length === 0 && (
<CommandEmpty>No repos found for &quot;{org}&quot;</CommandEmpty>
)}
{!searchLoading && !org && value.length > 0 && (
<CommandEmpty>Type owner/repo to search</CommandEmpty>
)}
{filteredResults.length > 0 && (
<CommandGroup>
{filteredResults.map((repo) => (
<CommandItem
key={repo.full_name}
value={repo.full_name}
onSelect={handleSelect}
className="cursor-pointer"
>
{repo.full_name}
{repo.private && (
<span className="ml-auto text-xs text-muted-foreground">private</span>
)}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function RepoFilterSelector({
allRepos,
selectedRepos,
onAllReposChange,
onSelectedReposChange,
}: RepoFilterSelectorProps) {
const { orgs, loading: orgsLoading } = useGitHubOrgs();
const toggleOrg = useCallback(
(login: string) => {
const exists = selectedRepos.some((r) => r.owner === login && r.name === "");
if (exists) {
onSelectedReposChange(selectedRepos.filter((r) => !(r.owner === login && r.name === "")));
} else {
onSelectedReposChange([...selectedRepos, { owner: login, name: "" }]);
}
},
[selectedRepos, onSelectedReposChange],
);
const addRepo = useCallback(
(owner: string, name: string) => {
const exists = selectedRepos.some((r) => r.owner === owner && r.name === name);
if (!exists) {
onSelectedReposChange([...selectedRepos, { owner, name }]);
}
},
[selectedRepos, onSelectedReposChange],
);
const removeFilter = useCallback(
(filter: RepoFilter) => {
onSelectedReposChange(
selectedRepos.filter((r) => !(r.owner === filter.owner && r.name === filter.name)),
);
},
[selectedRepos, onSelectedReposChange],
);
return (
<div className="space-y-3">
<div>
<Label>Repositories</Label>
<p className="text-xs text-muted-foreground">
Which GitHub repositories to monitor for PRs requesting your review.
</p>
</div>
<div className="flex items-center gap-2">
<Switch
id="all-repos-toggle"
checked={allRepos}
onCheckedChange={onAllReposChange}
className="cursor-pointer"
/>
<Label htmlFor="all-repos-toggle" className="font-normal cursor-pointer">
All repositories (no filtering)
</Label>
</div>
{!allRepos && (
<>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-normal">Organizations</Label>
<OrgBadges
orgs={orgs}
loading={orgsLoading}
selectedRepos={selectedRepos}
disabled={allRepos}
onToggleOrg={toggleOrg}
/>
</div>
<RepoSearchCombobox disabled={allRepos} onAdd={addRepo} />
<SelectedFilters repos={selectedRepos} onRemove={removeFilter} disabled={allRepos} />
</>
)}
</div>
);
}
@@ -0,0 +1,507 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@kandev/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { IconInfoCircle } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { listWorkflowSteps } from "@/lib/api/domains/workflow-api";
import { ScriptEditor } from "@/components/settings/profile-edit/script-editor";
import {
REVIEW_WATCH_PLACEHOLDERS,
DEFAULT_REVIEW_WATCH_PROMPT,
} from "@/components/github/review-watch-placeholders";
import { RepoFilterSelector } from "@/components/github/repo-filter-selector";
import type {
RepoFilter,
ReviewWatch,
CreateReviewWatchRequest,
UpdateReviewWatchRequest,
} from "@/lib/types/github";
type ReviewWatchDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
watch: ReviewWatch | null;
workspaceId: string;
onCreate: (req: CreateReviewWatchRequest) => Promise<void>;
onUpdate: (id: string, req: UpdateReviewWatchRequest) => Promise<void>;
};
type FormState = {
selectedRepos: RepoFilter[];
allRepos: boolean;
workflowId: string;
workflowStepId: string;
agentProfileId: string;
executorProfileId: string;
prompt: string;
enabled: boolean;
pollInterval: number;
};
type WorkflowStepOption = { id: string; name: string };
const defaultFormState: FormState = {
selectedRepos: [],
allRepos: false,
workflowId: "",
workflowStepId: "",
agentProfileId: "",
executorProfileId: "",
prompt: DEFAULT_REVIEW_WATCH_PROMPT,
enabled: true,
pollInterval: 300,
};
function formStateFromWatch(watch: ReviewWatch): FormState {
const hasRepos = watch.repos && watch.repos.length > 0;
return {
selectedRepos: hasRepos ? watch.repos : [],
allRepos: !hasRepos,
workflowId: watch.workflow_id,
workflowStepId: watch.workflow_step_id,
agentProfileId: watch.agent_profile_id,
executorProfileId: watch.executor_profile_id,
prompt: watch.prompt || DEFAULT_REVIEW_WATCH_PROMPT,
enabled: watch.enabled,
pollInterval: watch.poll_interval_seconds,
};
}
// --- Generic select field with description ---
type SelectFieldProps = {
label: string;
description?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
items: Array<{ id: string; label: string }>;
};
function SelectField({
label,
description,
value,
onChange,
placeholder,
items,
}: SelectFieldProps) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// --- Hooks ---
function useWorkflowSteps(workflowId: string) {
const [steps, setSteps] = useState<WorkflowStepOption[]>([]);
useEffect(() => {
if (!workflowId) {
void Promise.resolve().then(() => setSteps([]));
return;
}
let cancelled = false;
listWorkflowSteps(workflowId)
.then((response) => {
if (cancelled) return;
const sorted = [...response.steps].sort((a, b) => a.position - b.position);
setSteps(sorted.map((s) => ({ id: s.id, name: s.name })));
})
.catch(() => {
if (!cancelled) setSteps([]);
});
return () => {
cancelled = true;
};
}, [workflowId]);
return steps;
}
function useWatchFormData(workspaceId: string) {
useSettingsData(true);
useWorkflows(workspaceId, true);
const workflows = useAppStore((state) => state.workflows.items);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const executors = useAppStore((state) => state.executors.items);
const allExecutorProfiles = useMemo(
() => executors.flatMap((e) => e.profiles ?? []),
[executors],
);
return { workflows, agentProfiles, allExecutorProfiles };
}
// --- Form field groups ---
function WatchFormFields({
form,
setForm,
workspaceId,
onAllReposChange,
onSelectedReposChange,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
workspaceId: string;
onAllReposChange: (checked: boolean) => void;
onSelectedReposChange: (repos: RepoFilter[]) => void;
}) {
const { workflows, agentProfiles, allExecutorProfiles } = useWatchFormData(workspaceId);
const workflowSteps = useWorkflowSteps(form.workflowId);
const handleWorkflowChange = useCallback(
(v: string) => {
setForm((prev) => ({ ...prev, workflowId: v, workflowStepId: "" }));
},
[setForm],
);
return (
<div className="space-y-5">
<RepoFilterSelector
allRepos={form.allRepos}
selectedRepos={form.selectedRepos}
onAllReposChange={onAllReposChange}
onSelectedReposChange={onSelectedReposChange}
/>
<WorkflowFields
form={form}
setForm={setForm}
workflows={workflows}
workflowSteps={workflowSteps}
onWorkflowChange={handleWorkflowChange}
/>
<ProfileFields
form={form}
setForm={setForm}
agentProfiles={agentProfiles}
executorProfiles={allExecutorProfiles}
/>
<PromptField form={form} setForm={setForm} />
<SettingsFields form={form} setForm={setForm} />
</div>
);
}
function WorkflowFields({
form,
setForm,
workflows,
workflowSteps,
onWorkflowChange,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
workflows: Array<{ id: string; name: string }>;
workflowSteps: WorkflowStepOption[];
onWorkflowChange: (v: string) => void;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Workflow"
description="The workflow to create tasks in."
value={form.workflowId}
onChange={onWorkflowChange}
placeholder="Select workflow"
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
/>
<SelectField
label="Workflow Step"
description="Initial step for new tasks. Auto-start is set on the step."
value={form.workflowStepId}
onChange={(v) => setForm((prev) => ({ ...prev, workflowStepId: v }))}
placeholder={form.workflowId ? "Select step" : "Select a workflow first"}
items={workflowSteps.map((s) => ({ id: s.id, label: s.name }))}
/>
</div>
);
}
function HelpTip({ text }: { text: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">{text}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function ProfileFields({
form,
setForm,
agentProfiles,
executorProfiles,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
agentProfiles: Array<{ id: string; label: string }>;
executorProfiles: Array<{ id: string; name: string }>;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Agent Profile"
description="The agent configuration used to review the PR."
value={form.agentProfileId}
onChange={(v) => setForm((prev) => ({ ...prev, agentProfileId: v }))}
placeholder="Select agent profile"
items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))}
/>
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label>Executor Profile</Label>
<HelpTip text="The repository will be automatically cloned to ~/.kandev/repos/<owner>/<repo> if it is not already present in the workspace." />
</div>
<p className="text-xs text-muted-foreground">
The executor environment where the agent will run.
</p>
<Select
value={form.executorProfileId || undefined}
onValueChange={(v) => setForm((prev) => ({ ...prev, executorProfileId: v }))}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select executor profile" />
</SelectTrigger>
<SelectContent>
{executorProfiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
function PlaceholdersHelp() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs" align="start">
<p className="text-xs font-medium mb-1">Available placeholders:</p>
<ul className="text-xs space-y-0.5">
{REVIEW_WATCH_PLACEHOLDERS.map((p) => (
<li key={p.key}>
<code className="text-[10px] bg-white/15 px-1 rounded">{`{{${p.key}}}`}</code>{" "}
<span className="opacity-70">{p.description}</span>
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function PromptField({
form,
setForm,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label>Task Prompt</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
The prompt sent to the agent for each new PR. Type {"{{"} to insert placeholders.
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={form.prompt}
onChange={(v) => setForm((prev) => ({ ...prev, prompt: v }))}
language="plaintext"
height="120px"
lineNumbers="off"
placeholders={REVIEW_WATCH_PLACEHOLDERS}
/>
</div>
<p className="text-xs text-muted-foreground/70">
The workflow step prompt wraps this prompt. For example, if the step prompt is{" "}
<code className="text-[10px] bg-muted px-1 rounded">
{"Analyze the task: {{task_prompt}}"}
</code>
, the final prompt becomes{" "}
<code className="text-[10px] bg-muted px-1 rounded">
{"Analyze the task: Pull Request ready for review: https://..."}
</code>
</p>
</div>
);
}
function SettingsFields({
form,
setForm,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
return (
<>
<div className="space-y-1.5">
<Label>Poll Interval (seconds)</Label>
<p className="text-xs text-muted-foreground">
How often to check for new PRs. Minimum 60s, maximum 3600s.
</p>
<Input
type="number"
value={form.pollInterval}
onChange={(e) => setForm((prev) => ({ ...prev, pollInterval: Number(e.target.value) }))}
min={60}
max={3600}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Enabled</Label>
<p className="text-xs text-muted-foreground">Pause or resume polling.</p>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((prev) => ({ ...prev, enabled: v }))}
className="cursor-pointer"
/>
</div>
</>
);
}
function getSaveButtonLabel(saving: boolean, isEditing: boolean): string {
if (saving) return "Saving...";
return isEditing ? "Update" : "Create";
}
export function ReviewWatchDialog({
open,
onOpenChange,
watch,
workspaceId,
onCreate,
onUpdate,
}: ReviewWatchDialogProps) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(defaultFormState);
useEffect(() => {
setForm(watch ? formStateFromWatch(watch) : defaultFormState);
}, [watch, open]);
const handleSelectedReposChange = useCallback((repos: RepoFilter[]) => {
setForm((prev) => ({ ...prev, selectedRepos: repos }));
}, []);
const handleAllReposChange = useCallback((checked: boolean) => {
setForm((prev) => ({
...prev,
allRepos: checked,
selectedRepos: checked ? [] : prev.selectedRepos,
}));
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const repos = form.allRepos ? [] : form.selectedRepos;
const payload = {
workflow_id: form.workflowId,
workflow_step_id: form.workflowStepId,
repos,
agent_profile_id: form.agentProfileId,
executor_profile_id: form.executorProfileId,
prompt: form.prompt,
enabled: form.enabled,
poll_interval_seconds: form.pollInterval,
};
if (watch) {
await onUpdate(watch.id, payload);
} else {
await onCreate({ ...payload, workspace_id: workspaceId });
}
onOpenChange(false);
} catch {
// Error handled by caller
} finally {
setSaving(false);
}
}, [form, watch, workspaceId, onCreate, onUpdate, onOpenChange]);
const hasValidRepos = form.allRepos || form.selectedRepos.length > 0;
const canSave =
hasValidRepos && !!form.workflowId && !!form.workflowStepId && form.prompt.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full sm:w-[900px] sm:max-w-none max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{watch ? "Edit Review Watch" : "Create Review Watch"}</DialogTitle>
<DialogDescription>
Automatically create tasks when new pull requests need your review.
</DialogDescription>
</DialogHeader>
<WatchFormFields
form={form}
setForm={setForm}
workspaceId={workspaceId}
onAllReposChange={handleAllReposChange}
onSelectedReposChange={handleSelectedReposChange}
/>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !canSave} className="cursor-pointer">
{getSaveButtonLabel(saving, !!watch)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,48 @@
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export const REVIEW_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "pr.link",
description: "PR URL",
example: "https://github.com/org/repo/pull/123",
executor_types: [],
},
{
key: "pr.number",
description: "PR number",
example: "123",
executor_types: [],
},
{
key: "pr.title",
description: "PR title",
example: "Add user authentication",
executor_types: [],
},
{
key: "pr.author",
description: "PR author username",
example: "octocat",
executor_types: [],
},
{
key: "pr.repo",
description: "Repository (owner/name)",
example: "org/repo",
executor_types: [],
},
{
key: "pr.branch",
description: "Source branch",
example: "feature/auth",
executor_types: [],
},
{
key: "pr.base_branch",
description: "Target branch",
example: "main",
executor_types: [],
},
];
export const DEFAULT_REVIEW_WATCH_PROMPT = "Pull Request ready for review: {{pr.link}}";
@@ -0,0 +1,159 @@
"use client";
import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import type { ReviewWatch } from "@/lib/types/github";
type ReviewWatchTableProps = {
watches: ReviewWatch[];
onEdit: (watch: ReviewWatch) => void;
onDelete: (id: string) => void;
onTrigger: (id: string) => void;
onToggleEnabled: (watch: ReviewWatch) => void;
};
function formatLastPolled(dateStr: string | null): string {
if (!dateStr) return "Never";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatRepoNames(watch: ReviewWatch): string {
if (watch.repos.length === 0) return "All repositories";
return watch.repos
.map((r) => (r.name === "" ? `${r.owner}/*` : `${r.owner}/${r.name}`))
.join(", ");
}
type WatchActionsProps = {
watch: ReviewWatch;
onToggleEnabled: (watch: ReviewWatch) => void;
onTrigger: (id: string) => void;
onDelete: (id: string) => void;
};
function WatchActions({ watch, onToggleEnabled, onTrigger, onDelete }: WatchActionsProps) {
const { toast } = useToast();
return (
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(watch);
}}
>
{watch.enabled ? (
<IconPlayerPause className="h-3.5 w-3.5" />
) : (
<IconPlayerPlay className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onTrigger(watch.id);
toast({ description: "Checking for new PRs..." });
}}
>
<IconRefresh className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Check now</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(watch.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
);
}
export function ReviewWatchTable({
watches,
onEdit,
onDelete,
onTrigger,
onToggleEnabled,
}: ReviewWatchTableProps) {
if (watches.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
No review watches configured. Create one to start monitoring PRs.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Last Polled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{watches.map((watch) => (
<TableRow key={watch.id} className="cursor-pointer" onClick={() => onEdit(watch)}>
<TableCell className="font-medium">{formatRepoNames(watch)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{Math.round(watch.poll_interval_seconds / 60)}m
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLastPolled(watch.last_polled_at)}
</TableCell>
<TableCell>
<Badge variant={watch.enabled ? "default" : "secondary"} className="text-xs">
{watch.enabled ? "Active" : "Paused"}
</Badge>
</TableCell>
<TableCell className="text-right">
<WatchActions
watch={watch}
onToggleEnabled={onToggleEnabled}
onTrigger={onTrigger}
onDelete={onDelete}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}