diff --git a/frontend/src/app/git-projects/issues/page.tsx b/frontend/src/app/git-projects/issues/page.tsx index ff32ecf..bcb8adc 100644 --- a/frontend/src/app/git-projects/issues/page.tsx +++ b/frontend/src/app/git-projects/issues/page.tsx @@ -2,12 +2,22 @@ export const dynamic = "force-dynamic"; -import { useState, useEffect, useMemo } from "react"; +import { type ReactNode, useState, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; -import { AlertCircle } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + CircleDot, + GitBranch, + Loader2, + Plus, + RefreshCw, + Timer, +} from "lucide-react"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { ApiError } from "@/api/mutator"; import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, @@ -56,6 +66,74 @@ const isRecentlyClosedIssue = ( return closedAt.getTime() >= cutoffMs; }; +type StatTone = "blue" | "green" | "amber" | "rose" | "violet"; + +const statToneClasses: Record< + StatTone, + { card: string; icon: string; glow: string } +> = { + blue: { + card: "border-[color:rgba(96,165,250,0.34)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),var(--surface)_46%)]", + icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:rgba(96,165,250,0.14)] text-[color:var(--accent-strong)]", + glow: "bg-[color:rgba(96,165,250,0.32)]", + }, + green: { + card: "border-[color:rgba(52,211,153,0.34)] bg-[linear-gradient(145deg,rgba(52,211,153,0.15),var(--surface)_46%)]", + icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]", + glow: "bg-[color:rgba(52,211,153,0.28)]", + }, + amber: { + card: "border-[color:rgba(251,191,36,0.36)] bg-[linear-gradient(145deg,rgba(251,191,36,0.14),var(--surface)_46%)]", + icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]", + glow: "bg-[color:rgba(251,191,36,0.28)]", + }, + rose: { + card: "border-[color:rgba(248,113,113,0.34)] bg-[linear-gradient(145deg,rgba(248,113,113,0.14),var(--surface)_46%)]", + icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]", + glow: "bg-[color:rgba(248,113,113,0.26)]", + }, + violet: { + card: "border-[color:rgba(168,85,247,0.32)] bg-[linear-gradient(145deg,rgba(168,85,247,0.14),var(--surface)_46%)]", + icon: "border-[color:rgba(168,85,247,0.28)] bg-[color:rgba(168,85,247,0.13)] text-[color:rgb(196,181,253)]", + glow: "bg-[color:rgba(168,85,247,0.25)]", + }, +}; + +function IssueStatCard({ + icon, + label, + value, + caption, + tone, +}: { + icon: ReactNode; + label: string; + value: string; + caption: string; + tone: StatTone; +}) { + const colors = statToneClasses[tone]; + return ( +
+ +
+
+

+ {label} +

+

{value}

+
+
{icon}
+
+

{caption}

+
+ ); +} + export default function GitIssuesPage() { const searchParams = useSearchParams(); const initialStaleOnly = searchParams.get("stale") === "1"; @@ -208,6 +286,38 @@ export default function GitIssuesPage() { const isClientFiltered = staleOnly || recentClosedOnly; const visibleTotal = isClientFiltered ? visibleIssues.length : total; const totalPages = isClientFiltered ? 1 : Math.ceil(total / limit); + const openIssueCount = useMemo( + () => visibleIssues.filter((issue) => issue.state === "open").length, + [visibleIssues], + ); + const closedIssueCount = useMemo( + () => visibleIssues.filter((issue) => issue.state === "closed").length, + [visibleIssues], + ); + const staleIssueCount = useMemo( + () => + visibleIssues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs)) + .length, + [staleCutoffMs, visibleIssues], + ); + const pullRequestCount = useMemo( + () => issues.filter((issue) => issue.is_pull_request).length, + [issues], + ); + const activeRepositoryName = useMemo(() => { + if (repoFilter === "all") return "All repositories"; + const repo = repos.find((candidate) => candidate.id === repoFilter); + return repo + ? repo.display_name || `${repo.owner}/${repo.repo}` + : "Selected repository"; + }, [repoFilter, repos]); + const activeModeLabel = staleOnly + ? "Stale review" + : recentClosedOnly + ? "Recently closed" + : stateFilter === "all" + ? "All issue states" + : `${stateFilter[0]?.toUpperCase() ?? ""}${stateFilter.slice(1)} issues`; return ( + + {canCreateIssues && repos.length > 0 ? ( + + ) : null} + + } > -
+
+
+
+
+
+ 0 ? "warning" : "success"} + className="w-fit shadow-[0_0_24px_rgba(96,165,250,0.16)]" + > + {staleIssueCount > 0 ? `${staleIssueCount} stale` : "Current"} + +

+ Issue Operations +

+

+ {activeModeLabel} across {activeRepositoryName}. Pull requests + are hidden from the issue workflow unless a filter explicitly + requests upstream totals. +

+
+
+ + +
+
+
+ } + label="Open" + value={String(openIssueCount)} + caption="Visible issues still active." + tone="green" + /> + } + label="Closed" + value={String(closedIssueCount)} + caption={`Closed in this result set.`} + tone="violet" + /> + } + label="Stale" + value={String(staleIssueCount)} + caption={`${STALE_ISSUE_DAYS}+ days without updates.`} + tone={staleIssueCount > 0 ? "amber" : "blue"} + /> + } + label="Scope" + value={repoFilter === "all" ? String(repos.length) : "1"} + caption={`${pullRequestCount} pull requests excluded.`} + tone="blue" + /> +
+
+ { @@ -241,96 +455,88 @@ export default function GitIssuesPage() { }} repos={repos} /> - {canCreateIssues && repos.length > 0 ? ( - - ) : null} -
- {staleOnly ? ( -
- - Showing open issues not updated in {STALE_ISSUE_DAYS}+ days. - - -
- ) : null} - - {recentClosedOnly ? ( -
- - Showing issues closed in the last {RECENT_CLOSED_DAYS} days. - - -
- ) : null} - - {error ? ( -
- - {error} -
- ) : null} - - - - {totalPages > 1 && ( -
- - Page {page} of {totalPages} ({visibleTotal} total) - -
+ {staleOnly ? ( +
+ + Showing open issues not updated in {STALE_ISSUE_DAYS}+ days. + -
-
- )} + ) : null} + + {recentClosedOnly ? ( +
+ + Showing issues closed in the last {RECENT_CLOSED_DAYS} days. + + +
+ ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + + + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} ({visibleTotal} total) + +
+ + +
+
+ )} +
{ cancelled = true; }; + return () => { + cancelled = true; + }; }, [open, issue]); const agentsQuery = useListAgentsApiV1AgentsGet< @@ -138,13 +140,9 @@ export function AssignIssueAgentDialog({ ); const agents = - agentsQuery.data?.status === 200 - ? (agentsQuery.data.data.items ?? []) - : []; + agentsQuery.data?.status === 200 ? (agentsQuery.data.data.items ?? []) : []; const allBoards = - boardsQuery.data?.status === 200 - ? (boardsQuery.data.data.items ?? []) - : []; + boardsQuery.data?.status === 200 ? (boardsQuery.data.data.items ?? []) : []; useEffect(() => { if (!open) { @@ -236,7 +234,7 @@ export function AssignIssueAgentDialog({ disabled={disabled} className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${ value - ? "border-emerald-600 bg-emerald-600" + ? "border-[color:var(--success)] bg-[color:var(--success)]" : "border-[color:var(--border)] bg-[color:var(--surface-muted)]" } ${disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`} > @@ -255,8 +253,12 @@ export function AssignIssueAgentDialog({ if (!isSubmitting) onOpenChange(next); }} > - - + +
+ +
+ +
Assign to agent Create a Pipeline task for{" "} @@ -268,36 +270,41 @@ export function AssignIssueAgentDialog({
-
+

{issue.title}

{noLinkedBoards ? ( -
+

Repository not linked to any board

Before you can assign an agent, link{" "} - {repositoryName}{" "} - to the board that should own this task. Open the target board and - use its Git Project repositories panel, or manage tracked + + {repositoryName} + {" "} + to the board that should own this task. Open the target board + and use its Git Project repositories panel, or manage tracked repositories first.

setPriority(e.target.value)} disabled={isSubmitting} - className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]" + className="w-full rounded-xl border border-[color:rgba(168,85,247,0.2)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]" > @@ -406,7 +415,8 @@ export function AssignIssueAgentDialog({