mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: git hygiene (branch/preview reaping + cleanup sweep) and panel charts; work sessions under Git (#548)
* feat(panel): session-start, 7d overview spend, and 30d business spend charts * feat(panel): surface work sessions as a Git page tab (route was orphaned) * feat(git): reap spent task branches and render previews at lifecycle chokepoints; guarded stale-branch sweep * feat(panel): stale-branch cleanup button on the Git page * fix(git,panel): cursor-resumable sweep, force-delete spent refs, local filter state * docs(map,rag): branch/preview reaping, cleanup sweep, git-tab work sessions, wave-2 charts --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,99 @@
|
||||
import { GitBrowser } from "@/components/git";
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { GitBrowser } from "@/components/git";
|
||||
import { WorkSessionsView } from "@/components/work-sessions";
|
||||
|
||||
interface TabDef {
|
||||
value: "repository" | "sessions";
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const TAB_DEFS: TabDef[] = [
|
||||
{
|
||||
value: "repository",
|
||||
label: "Repository",
|
||||
hint: "Browse status, branches, log, and diffs; run git actions",
|
||||
},
|
||||
{
|
||||
value: "sessions",
|
||||
label: "Work Sessions",
|
||||
hint: "Active agent work sessions — branch, commits, and PR per task",
|
||||
},
|
||||
];
|
||||
|
||||
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
function isValidTab(value: string | null): value is TabValue {
|
||||
return TAB_VALUES.includes(value as TabValue);
|
||||
}
|
||||
|
||||
function GitPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "repository";
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.replace(`/git?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
{TAB_DEFS.map((tab) => (
|
||||
<Tooltip key={tab.value}>
|
||||
<TooltipTrigger asChild>
|
||||
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
|
||||
own data-state; re-stamp it so the active style survives. */}
|
||||
<TabsTrigger
|
||||
value={tab.value}
|
||||
data-state={tab.value === activeTab ? "active" : "inactive"}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tab.hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="repository" className="mt-4">
|
||||
<GitBrowser />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="mt-4">
|
||||
<WorkSessionsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function GitPage() {
|
||||
return <GitBrowser />;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<GitPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,157 +1,6 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { Suspense, useMemo, useCallback, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useWorkSessions } from "@/hooks/use-work-sessions";
|
||||
import { WorkSessionStatus } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import {
|
||||
WorkSessionTable,
|
||||
WorkSessionFilters,
|
||||
} from "@/components/work-sessions";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
|
||||
function WorkSessionsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Read state from URL params
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const statusParam = searchParams.get("status");
|
||||
const statusFilter = useMemo(
|
||||
() =>
|
||||
(statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
|
||||
[statusParam],
|
||||
);
|
||||
|
||||
// Update URL params
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/work-sessions?${query}` : "/work-sessions");
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({ q: value || null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(value: WorkSessionStatus[]) => {
|
||||
updateParams({ status: value.length > 0 ? value.join(",") : null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Fetch work sessions
|
||||
const { data: sessions, isLoading, error, refetch } = useWorkSessions();
|
||||
|
||||
const { register, unregister, refresh } = usePageRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
void refetch();
|
||||
};
|
||||
register(cb);
|
||||
return () => unregister(cb);
|
||||
}, [register, unregister, refetch]);
|
||||
|
||||
// Filter sessions client-side for search and multi-select status filter
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
|
||||
return sessions.filter((session) => {
|
||||
// Search filter - match branch name
|
||||
if (
|
||||
searchQuery &&
|
||||
!session.branch_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter (if any selected, session must match one of them)
|
||||
if (statusFilter.length > 0 && !statusFilter.includes(session.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [sessions, searchQuery, statusFilter]);
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Work Sessions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track git branches and pull requests for active work
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters - Sticky */}
|
||||
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
|
||||
<WorkSessionFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load Work Sessions"
|
||||
description="Start the RoboCo orchestrator to view work sessions. Work sessions track agent activity on git branches."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<WorkSessionTable sessions={filteredSessions} isLoading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
// The work-sessions surface moved under /git as its "Work Sessions" tab.
|
||||
export default function WorkSessionsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-72" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<WorkSessionsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
redirect("/git?tab=sessions");
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ vi.mock("@/lib/api/cockpit", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The spend trend chart pulls its own series via useUsageTimeSeries — a
|
||||
// hook-level mock (not the raw react-query one above, which only controls
|
||||
// the single cockpit-summary useQuery call) so SpendTrendChart never sees
|
||||
// the mocked CockpitSummary object where it expects an array.
|
||||
vi.mock("@/hooks/use-usage", () => ({
|
||||
useUsageTimeSeries: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import component AFTER mocks are set up
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,6 +171,17 @@ describe("CompanyScorecardCard", () => {
|
||||
expect(screen.getByText("Done (30 d)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The spend-trend chart is wired into the Spend section
|
||||
// -------------------------------------------------------------------------
|
||||
it("renders the daily spend trend chart alongside the spend summary", () => {
|
||||
setQueryState({ data: buildSummary() });
|
||||
|
||||
render(<CompanyScorecardCard />);
|
||||
|
||||
expect(screen.getByText("Daily Spend (30d)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// AC2 Scenario 4: Spend — 'No budget cap set' when cap is null
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SpendTrendChart } from "../spend-trend-chart";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
function buildPoint(overrides: Partial<UsageTimePoint> = {}): UsageTimePoint {
|
||||
return {
|
||||
bucket: new Date().toISOString(),
|
||||
tokens_input: 1000,
|
||||
tokens_output: 500,
|
||||
total_tokens: 1500,
|
||||
cost_usd: 4.56,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SpendTrendChart", () => {
|
||||
it("renders the card title", () => {
|
||||
render(<SpendTrendChart data={[buildPoint()]} isLoading={false} />);
|
||||
expect(screen.getByText("Daily Spend (30d)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when there is no data", () => {
|
||||
render(<SpendTrendChart data={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No spend data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when data is undefined", () => {
|
||||
render(<SpendTrendChart data={undefined} isLoading={false} />);
|
||||
expect(screen.getByText("No spend data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<SpendTrendChart data={[]} isLoading />);
|
||||
expect(screen.queryByText("No spend data")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { useUsageTimeSeries } from "@/hooks/use-usage";
|
||||
import { SpendTrendChart } from "./spend-trend-chart";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton — three grouped skeleton blocks
|
||||
@@ -131,9 +134,15 @@ function DeliverySection({ delivery }: DeliverySectionProps) {
|
||||
|
||||
interface SpendSectionProps {
|
||||
spend: CockpitSummary["spend"];
|
||||
spendTrend: UsageTimePoint[] | undefined;
|
||||
spendTrendLoading: boolean;
|
||||
}
|
||||
|
||||
function SpendSection({ spend }: SpendSectionProps) {
|
||||
function SpendSection({
|
||||
spend,
|
||||
spendTrend,
|
||||
spendTrendLoading,
|
||||
}: SpendSectionProps) {
|
||||
const {
|
||||
monthly_budget_cap_usd,
|
||||
spend_30d_usd,
|
||||
@@ -190,6 +199,7 @@ function SpendSection({ spend }: SpendSectionProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SpendTrendChart data={spendTrend} isLoading={spendTrendLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -266,9 +276,15 @@ function StubObjectivesSection() {
|
||||
|
||||
interface ScorecardBodyProps {
|
||||
data: CockpitSummary;
|
||||
spendTrend: UsageTimePoint[] | undefined;
|
||||
spendTrendLoading: boolean;
|
||||
}
|
||||
|
||||
function ScorecardBody({ data }: ScorecardBodyProps) {
|
||||
function ScorecardBody({
|
||||
data,
|
||||
spendTrend,
|
||||
spendTrendLoading,
|
||||
}: ScorecardBodyProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -277,7 +293,11 @@ function ScorecardBody({ data }: ScorecardBodyProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DeliverySection delivery={data.delivery} />
|
||||
<SpendSection spend={data.spend} />
|
||||
<SpendSection
|
||||
spend={data.spend}
|
||||
spendTrend={spendTrend}
|
||||
spendTrendLoading={spendTrendLoading}
|
||||
/>
|
||||
<SpeedSection medianLeadTimeHours={data.median_lead_time_hours} />
|
||||
<StubObjectivesSection />
|
||||
</CardContent>
|
||||
@@ -294,6 +314,8 @@ export function CompanyScorecardCard() {
|
||||
queryKey: ["cockpit-summary"],
|
||||
queryFn: cockpitApi.summary,
|
||||
});
|
||||
const { data: spendTrend, isLoading: spendTrendLoading } =
|
||||
useUsageTimeSeries("30d");
|
||||
|
||||
if (isLoading) return <ScorecardSkeleton />;
|
||||
|
||||
@@ -307,5 +329,11 @@ export function CompanyScorecardCard() {
|
||||
);
|
||||
}
|
||||
|
||||
return <ScorecardBody data={data} />;
|
||||
return (
|
||||
<ScorecardBody
|
||||
data={data}
|
||||
spendTrend={spendTrend}
|
||||
spendTrendLoading={spendTrendLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
interface SpendTrendChartProps {
|
||||
data: UsageTimePoint[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatBucket(bucket: string): string {
|
||||
const d = new Date(bucket);
|
||||
return d.getMonth() + 1 + "/" + d.getDate();
|
||||
}
|
||||
|
||||
function fmtCost(n: number): string {
|
||||
return "$" + n.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-spend breakdown behind the Scorecard's "30-day spend" figure —
|
||||
* reuses GET /usage/time-series (period=30d, daily buckets), the same
|
||||
* series-shaped endpoint the Overview page's CostTrendChart draws from.
|
||||
*/
|
||||
export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) {
|
||||
const chartData = (data ?? []).map((p) => ({
|
||||
day: formatBucket(p.bucket),
|
||||
Spend: p.cost_usd,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<HelpTip label="Provider-priced agent-session cost per day over the trailing 30 days — the daily breakdown behind the 30-day spend total above">
|
||||
<CardTitle className="text-base">Daily Spend (30d)</CardTitle>
|
||||
</HelpTip>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
No spend data
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 9 }}
|
||||
interval={4}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmtCost}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
fmtCost(typeof value === "number" ? value : 0),
|
||||
"Spend",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="Spend" fill="var(--chart-3)" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,13 @@ vi.mock("@/hooks/use-tasks", () => ({
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-usage", () => ({
|
||||
useUsageTimeSeries: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../team-health-cards", () => ({
|
||||
TeamHealthCards: () => <div>TeamHealthCardsStub</div>,
|
||||
@@ -87,6 +94,9 @@ vi.mock("../usage-overview-panel", () => ({
|
||||
vi.mock("../scorecard-overview-panel", () => ({
|
||||
ScorecardOverviewPanel: () => <div>ScorecardOverviewPanelStub</div>,
|
||||
}));
|
||||
vi.mock("../cost-trend-chart", () => ({
|
||||
CostTrendChart: () => <div>CostTrendChartStub</div>,
|
||||
}));
|
||||
|
||||
import { CommandCenter } from "../command-center";
|
||||
|
||||
@@ -106,6 +116,7 @@ describe("CommandCenter", () => {
|
||||
"AuditorAlertsPanelStub",
|
||||
"UsageOverviewPanelStub",
|
||||
"ScorecardOverviewPanelStub",
|
||||
"CostTrendChartStub",
|
||||
"TeamHealthCardsStub",
|
||||
"CeoApprovalQueueStub",
|
||||
"StrategySignalsPanelStub",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CostTrendChart } from "../cost-trend-chart";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
function buildPoint(overrides: Partial<UsageTimePoint> = {}): UsageTimePoint {
|
||||
return {
|
||||
bucket: new Date().toISOString(),
|
||||
tokens_input: 1000,
|
||||
tokens_output: 500,
|
||||
total_tokens: 1500,
|
||||
cost_usd: 1.23,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CostTrendChart", () => {
|
||||
it("renders the card title", () => {
|
||||
render(<CostTrendChart data={[buildPoint()]} isLoading={false} />);
|
||||
expect(screen.getByText("Spend Trend (7d)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when there is no data", () => {
|
||||
render(<CostTrendChart data={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No usage data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when data is undefined", () => {
|
||||
render(<CostTrendChart data={undefined} isLoading={false} />);
|
||||
expect(screen.getByText("No usage data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<CostTrendChart data={[]} isLoading />);
|
||||
expect(screen.queryByText("No usage data")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useRecentActivity,
|
||||
} from "@/hooks/use-dashboard";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import { useUsageTimeSeries } from "@/hooks/use-usage";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { TeamHealthCards } from "./team-health-cards";
|
||||
import { KeyMetricsPanel } from "./key-metrics-panel";
|
||||
@@ -25,6 +26,7 @@ import type { Activity } from "./activity-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UsageOverviewPanel } from "./usage-overview-panel";
|
||||
import { ScorecardOverviewPanel } from "./scorecard-overview-panel";
|
||||
import { CostTrendChart } from "./cost-trend-chart";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -62,6 +64,11 @@ export function CommandCenter() {
|
||||
isError: errorActivity,
|
||||
refetch: refetchActivity,
|
||||
} = useRecentActivity(24);
|
||||
const {
|
||||
data: costTrend,
|
||||
isLoading: loadingCostTrend,
|
||||
refetch: refetchCostTrend,
|
||||
} = useUsageTimeSeries("7d");
|
||||
|
||||
const { register, unregister } = usePageRefresh();
|
||||
|
||||
@@ -79,6 +86,9 @@ export function CommandCenter() {
|
||||
() => {
|
||||
void refetchActivity();
|
||||
},
|
||||
() => {
|
||||
void refetchCostTrend();
|
||||
},
|
||||
];
|
||||
callbacks.forEach((cb) => register(cb));
|
||||
return () => {
|
||||
@@ -91,6 +101,7 @@ export function CommandCenter() {
|
||||
refetchFlags,
|
||||
refetchTasks,
|
||||
refetchActivity,
|
||||
refetchCostTrend,
|
||||
]);
|
||||
|
||||
const hasError = errorOverview || errorFlags || errorTasks || errorActivity;
|
||||
@@ -156,6 +167,8 @@ export function CommandCenter() {
|
||||
<ScorecardOverviewPanel />
|
||||
</div>
|
||||
|
||||
<CostTrendChart data={costTrend} isLoading={loadingCostTrend} />
|
||||
|
||||
{/* Section 2: Team Health (team cards + Task Intake + Secretary) */}
|
||||
<section>
|
||||
<HelpTip label="Per-team blocked ratio and throughput, plus the on-demand agents">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import type { UsageTimePoint } from "@/types";
|
||||
|
||||
interface CostTrendChartProps {
|
||||
data: UsageTimePoint[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatBucket(bucket: string): string {
|
||||
const d = new Date(bucket);
|
||||
return d.getMonth() + 1 + "/" + d.getDate();
|
||||
}
|
||||
|
||||
function fmtCost(n: number): string {
|
||||
return "$" + n.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact daily spend trend for the Command Center landing page — reuses
|
||||
* GET /usage/time-series (period=7d, daily buckets) so the CEO sees where
|
||||
* the current-period totals in UsageOverviewPanel came from at a glance.
|
||||
*/
|
||||
export function CostTrendChart({ data, isLoading }: CostTrendChartProps) {
|
||||
const chartData = (data ?? []).map((p) => ({
|
||||
day: formatBucket(p.bucket),
|
||||
Cost: p.cost_usd,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<HelpTip label="Provider-priced agent-session cost per day over the last 7 days">
|
||||
<CardTitle className="text-base">Spend Trend (7d)</CardTitle>
|
||||
</HelpTip>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
No usage data
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillCost" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--chart-3)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--chart-3)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={fmtCost}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
fmtCost(typeof value === "number" ? value : 0),
|
||||
"Cost",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Cost"
|
||||
stroke="var(--chart-3)"
|
||||
fill="url(#fillCost)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Download,
|
||||
RefreshCcw,
|
||||
GitGraph,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
|
||||
@@ -51,6 +52,7 @@ interface GitActionsPanelProps {
|
||||
onPull: () => void;
|
||||
onFetch: () => void;
|
||||
onRebase: (targetBranch: string) => void;
|
||||
onCleanupBranches: () => void;
|
||||
isCommitting: boolean;
|
||||
isPushing: boolean;
|
||||
isCreatingPR: boolean;
|
||||
@@ -58,6 +60,7 @@ interface GitActionsPanelProps {
|
||||
isPulling: boolean;
|
||||
isFetching: boolean;
|
||||
isRebasing: boolean;
|
||||
isCleaningUpBranches: boolean;
|
||||
}
|
||||
|
||||
export function GitActionsPanel({
|
||||
@@ -72,6 +75,7 @@ export function GitActionsPanel({
|
||||
onPull,
|
||||
onFetch,
|
||||
onRebase,
|
||||
onCleanupBranches,
|
||||
isCommitting,
|
||||
isPushing,
|
||||
isCreatingPR,
|
||||
@@ -79,6 +83,7 @@ export function GitActionsPanel({
|
||||
isPulling,
|
||||
isFetching,
|
||||
isRebasing,
|
||||
isCleaningUpBranches,
|
||||
}: GitActionsPanelProps) {
|
||||
void _agentId; // Reserved for future use
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
||||
@@ -513,6 +518,48 @@ export function GitActionsPanel({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Cleanup Stale Branches — destructive, requires confirmation */}
|
||||
<AlertDialog>
|
||||
<HelpTip label="Deletes the remote + local branch of every completed/cancelled task in this project. Never touches the default branch or an environment-ladder rung (head/qa/stag/prod).">
|
||||
<span className="block w-full">
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
disabled={isCleaningUpBranches}
|
||||
>
|
||||
{isCleaningUpBranches ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Clean Up Stale Branches
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</span>
|
||||
</HelpTip>
|
||||
<AlertDialogContent className="border-destructive bg-destructive/5">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clean up stale branches?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deletes the remote + local branch of every completed or
|
||||
cancelled task in <strong>{projectSlug}</strong>. The default
|
||||
branch and every environment-ladder rung are always skipped.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onCleanupBranches}
|
||||
>
|
||||
Clean Up
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Status Summary */}
|
||||
{status && (
|
||||
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
|
||||
|
||||
@@ -48,6 +48,7 @@ function GitBrowserContent() {
|
||||
handlePull,
|
||||
handleFetch,
|
||||
handleRebase,
|
||||
handleCleanupBranches,
|
||||
isCommitting,
|
||||
isPushing,
|
||||
isCreatingPR,
|
||||
@@ -57,6 +58,7 @@ function GitBrowserContent() {
|
||||
isRebasing,
|
||||
isCheckingOut,
|
||||
isCreatingBranch,
|
||||
isCleaningUpBranches,
|
||||
} = useGitBrowser();
|
||||
|
||||
if (isOffline) {
|
||||
@@ -139,6 +141,7 @@ function GitBrowserContent() {
|
||||
onPull={handlePull}
|
||||
onFetch={handleFetch}
|
||||
onRebase={handleRebase}
|
||||
onCleanupBranches={handleCleanupBranches}
|
||||
isCommitting={isCommitting}
|
||||
isPushing={isPushing}
|
||||
isCreatingPR={isCreatingPR}
|
||||
@@ -146,6 +149,7 @@ function GitBrowserContent() {
|
||||
isPulling={isPulling}
|
||||
isFetching={isFetching}
|
||||
isRebasing={isRebasing}
|
||||
isCleaningUpBranches={isCleaningUpBranches}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SessionTrendChart } from "../session-trend-chart";
|
||||
import { WorkSessionStatus } from "@/types";
|
||||
import type { WorkSessionSummary } from "@/types";
|
||||
|
||||
function buildSession(overrides: Partial<WorkSessionSummary> = {}): WorkSessionSummary {
|
||||
return {
|
||||
id: "session-1",
|
||||
task_id: "11111111-1111-1111-1111-111111111111",
|
||||
branch_name: "feature/backend/ABC12345",
|
||||
status: WorkSessionStatus.ACTIVE,
|
||||
started_at: new Date().toISOString(),
|
||||
has_pr: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionTrendChart", () => {
|
||||
it("renders the card title", () => {
|
||||
render(<SessionTrendChart sessions={[buildSession()]} isLoading={false} />);
|
||||
expect(screen.getByText("Active Session Starts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when there are no sessions", () => {
|
||||
render(<SessionTrendChart sessions={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No active sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when sessions is undefined", () => {
|
||||
render(<SessionTrendChart sessions={undefined} isLoading={false} />);
|
||||
expect(screen.getByText("No active sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<SessionTrendChart sessions={[]} isLoading />);
|
||||
expect(screen.queryByText("No active sessions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,4 @@
|
||||
export { WorkSessionTable } from "./work-session-table";
|
||||
export { WorkSessionFilters } from "./work-session-filters";
|
||||
export { SessionTrendChart } from "./session-trend-chart";
|
||||
export { WorkSessionsView } from "./work-sessions-view";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import type { WorkSessionSummary } from "@/types";
|
||||
|
||||
interface SessionTrendChartProps {
|
||||
sessions: WorkSessionSummary[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const HOURLY_SPAN_MS = 36 * 60 * 60 * 1000;
|
||||
|
||||
interface Bucket {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets session `started_at` timestamps by hour (span <= 36h) or by day
|
||||
* (wider span), mirroring the hourly/daily switch usage-time-series-chart
|
||||
* applies for its period-selected data.
|
||||
*/
|
||||
function bucketSessions(sessions: WorkSessionSummary[]): Bucket[] {
|
||||
if (sessions.length === 0) return [];
|
||||
|
||||
const times = sessions.map((s) => new Date(s.started_at).getTime());
|
||||
const spanMs = Math.max(...times) - Math.min(...times);
|
||||
const hourly = spanMs <= HOURLY_SPAN_MS;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of sessions) {
|
||||
const d = new Date(s.started_at);
|
||||
const key = hourly
|
||||
? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}`
|
||||
: `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const buckets: Bucket[] = Array.from(counts.entries()).map(([key, count]) => {
|
||||
const [y, m, day, hour] = key.split("-").map(Number);
|
||||
const d = new Date(y, m, day, hour ?? 0);
|
||||
const label = hourly
|
||||
? d.getHours().toString().padStart(2, "0") + ":00"
|
||||
: d.getMonth() + 1 + "/" + d.getDate();
|
||||
return { key, label, count };
|
||||
});
|
||||
|
||||
buckets.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-start volume trend for the Work Sessions page. `GET /work-sessions`
|
||||
* (unfiltered, as this page calls it) returns only currently ACTIVE sessions
|
||||
* — there is no tokens/cost/duration field on WorkSessionSummary (that data
|
||||
* lives in agent_spawn_sessions, a different table) and no historical depth
|
||||
* beyond whatever is active right now. So this charts what's honestly here:
|
||||
* a start-time distribution of the active sessions already on the page,
|
||||
* labeled accordingly rather than presented as a full history.
|
||||
*/
|
||||
export function SessionTrendChart({
|
||||
sessions,
|
||||
isLoading,
|
||||
}: SessionTrendChartProps) {
|
||||
const buckets = useMemo(() => bucketSessions(sessions ?? []), [sessions]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<HelpTip label="When today's currently active work sessions began, bucketed by hour or day — this list only ever shows active sessions, not full session history">
|
||||
<CardTitle className="text-base">Active Session Starts</CardTitle>
|
||||
</HelpTip>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : buckets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No active sessions
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={208}>
|
||||
<BarChart
|
||||
data={buckets}
|
||||
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
typeof value === "number" ? value : 0,
|
||||
"Sessions started",
|
||||
]}
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="count" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useWorkSessions } from "@/hooks/use-work-sessions";
|
||||
import { WorkSessionStatus } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { WorkSessionTable } from "./work-session-table";
|
||||
import { WorkSessionFilters } from "./work-session-filters";
|
||||
import { SessionTrendChart } from "./session-trend-chart";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
|
||||
/**
|
||||
* Work-sessions content, rendered as the "Work Sessions" tab of /git.
|
||||
* Filter state is LOCAL, deliberately not URL params: every URL write forks
|
||||
* ScrollRestoration's route key and force-scrolls <main> to top, so a
|
||||
* per-keystroke q= param made typing in the search box bounce the page.
|
||||
*/
|
||||
export function WorkSessionsView() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<WorkSessionStatus[]>([]);
|
||||
|
||||
const handleSearchChange = setSearchQuery;
|
||||
const handleStatusChange = setStatusFilter;
|
||||
|
||||
// Fetch work sessions
|
||||
const { data: sessions, isLoading, error, refetch } = useWorkSessions();
|
||||
|
||||
const { register, unregister, refresh } = usePageRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
void refetch();
|
||||
};
|
||||
register(cb);
|
||||
return () => unregister(cb);
|
||||
}, [register, unregister, refetch]);
|
||||
|
||||
// Filter sessions client-side for search and multi-select status filter
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
|
||||
return sessions.filter((session) => {
|
||||
// Search filter - match branch name
|
||||
if (
|
||||
searchQuery &&
|
||||
!session.branch_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter (if any selected, session must match one of them)
|
||||
if (statusFilter.length > 0 && !statusFilter.includes(session.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [sessions, searchQuery, statusFilter]);
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Work Sessions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track git branches and pull requests for active work
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters - Sticky */}
|
||||
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
|
||||
<WorkSessionFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load Work Sessions"
|
||||
description="Start the RoboCo orchestrator to view work sessions. Work sessions track agent activity on git branches."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SessionTrendChart sessions={filteredSessions} isLoading={isLoading} />
|
||||
<WorkSessionTable sessions={filteredSessions} isLoading={isLoading} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,7 @@ function buildMutations(overrides: Record<string, unknown> = {}) {
|
||||
pull: { mutateAsync: vi.fn(), isPending: false },
|
||||
fetch: { mutateAsync: vi.fn(), isPending: false },
|
||||
rebase: { mutateAsync: vi.fn(), isPending: false },
|
||||
cleanupBranches: { mutateAsync: vi.fn(), isPending: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -312,4 +313,41 @@ describe("useGitBrowser", () => {
|
||||
expect(result.current.isCommitting).toBe(true);
|
||||
expect(result.current.isPushing).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up branches for the current project and shows a count toast", async () => {
|
||||
const mutateAsync = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
remote_deleted: 3,
|
||||
local_deleted: 2,
|
||||
skipped: 1,
|
||||
errors: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
);
|
||||
mockUseGitOperations.mockReturnValue(
|
||||
buildMutations({ cleanupBranches: { mutateAsync, isPending: false } }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGitBrowser());
|
||||
await result.current.handleCleanupBranches();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mutateAsync).toHaveBeenCalledWith({ project_slug: "roboco" }),
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
"Cleaned up branches: 3 remote, 2 local, 1 skipped, 0 errors",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error toast when branch cleanup fails", async () => {
|
||||
const mutateAsync = vi.fn(() => Promise.reject(new Error("boom")));
|
||||
mockUseGitOperations.mockReturnValue(
|
||||
buildMutations({ cleanupBranches: { mutateAsync, isPending: false } }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGitBrowser());
|
||||
await result.current.handleCleanupBranches();
|
||||
|
||||
await waitFor(() => expect(mockToastError).toHaveBeenCalledWith("boom"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
@@ -45,6 +45,7 @@ export interface UseGitBrowserResult {
|
||||
handlePull: () => Promise<void>;
|
||||
handleFetch: () => Promise<void>;
|
||||
handleRebase: (targetBranch: string) => Promise<void>;
|
||||
handleCleanupBranches: () => Promise<void>;
|
||||
isCommitting: boolean;
|
||||
isPushing: boolean;
|
||||
isCreatingPR: boolean;
|
||||
@@ -54,6 +55,7 @@ export interface UseGitBrowserResult {
|
||||
isRebasing: boolean;
|
||||
isCheckingOut: boolean;
|
||||
isCreatingBranch: boolean;
|
||||
isCleaningUpBranches: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,8 +164,12 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
pull,
|
||||
fetch,
|
||||
rebase,
|
||||
cleanupBranches,
|
||||
} = useGitOperations();
|
||||
|
||||
// Resume point for a capped stale-branch sweep, per project.
|
||||
const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(null);
|
||||
|
||||
const handleCheckout = useCallback(
|
||||
async (branch: string) => {
|
||||
try {
|
||||
@@ -317,6 +323,35 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
[projectSlug, taskId, rebase],
|
||||
);
|
||||
|
||||
const handleCleanupBranches = useCallback(async () => {
|
||||
try {
|
||||
// Resume a capped sweep from where the last click stopped — without
|
||||
// the cursor the backend re-scans the identical first window forever.
|
||||
const cursor =
|
||||
cleanupCursorRef.current?.slug === projectSlug
|
||||
? cleanupCursorRef.current.cursor
|
||||
: undefined;
|
||||
const result = await cleanupBranches.mutateAsync({
|
||||
project_slug: projectSlug,
|
||||
...(cursor ? { after_cursor: cursor } : {}),
|
||||
});
|
||||
cleanupCursorRef.current =
|
||||
result.truncated && result.next_cursor
|
||||
? { slug: projectSlug, cursor: result.next_cursor }
|
||||
: null;
|
||||
const truncatedNote = result.truncated
|
||||
? " (cap reached — click again to continue where it stopped)"
|
||||
: "";
|
||||
toast.success(
|
||||
`Cleaned up branches: ${result.remote_deleted} remote, ` +
|
||||
`${result.local_deleted} local, ${result.skipped} skipped, ` +
|
||||
`${result.errors} errors${truncatedNote}`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [projectSlug, cleanupBranches]);
|
||||
|
||||
const isOffline =
|
||||
!!projectsError &&
|
||||
(projectsError.message?.includes("Network Error") ||
|
||||
@@ -349,6 +384,7 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
handlePull,
|
||||
handleFetch,
|
||||
handleRebase,
|
||||
handleCleanupBranches,
|
||||
isCommitting: commit.isPending,
|
||||
isPushing: push.isPending,
|
||||
isCreatingPR: createPR.isPending,
|
||||
@@ -358,5 +394,6 @@ export function useGitBrowser(): UseGitBrowserResult {
|
||||
isRebasing: rebase.isPending,
|
||||
isCheckingOut: checkout.isPending,
|
||||
isCreatingBranch: createBranch.isPending,
|
||||
isCleaningUpBranches: cleanupBranches.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
GitFetchResponse,
|
||||
GitRebaseRequest,
|
||||
GitRebaseResponse,
|
||||
GitBranchCleanupRequest,
|
||||
GitBranchCleanupResponse,
|
||||
} from "@/types/git";
|
||||
|
||||
// =============================================================================
|
||||
@@ -331,6 +333,27 @@ export function useGitRebase() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep a project's terminal-task branches (remote + local, PM/CEO only)
|
||||
*/
|
||||
export function useCleanupBranches() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
GitBranchCleanupResponse,
|
||||
Error,
|
||||
GitBranchCleanupRequest
|
||||
>({
|
||||
mutationFn: (request) => gitApi.cleanupBranches(request),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate branches — the sweep may have deleted several.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...gitKeys.all, "branches", variables.project_slug],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bundled Hook for Git Operations
|
||||
// =============================================================================
|
||||
@@ -348,6 +371,7 @@ export function useGitOperations() {
|
||||
const pull = useGitPull();
|
||||
const fetch = useGitFetch();
|
||||
const rebase = useGitRebase();
|
||||
const cleanupBranches = useCleanupBranches();
|
||||
|
||||
return {
|
||||
commit,
|
||||
@@ -359,5 +383,6 @@ export function useGitOperations() {
|
||||
pull,
|
||||
fetch,
|
||||
rebase,
|
||||
cleanupBranches,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
GitFetchResponse,
|
||||
GitRebaseRequest,
|
||||
GitRebaseResponse,
|
||||
GitBranchCleanupRequest,
|
||||
GitBranchCleanupResponse,
|
||||
} from "@/types/git";
|
||||
|
||||
// =============================================================================
|
||||
@@ -358,4 +360,28 @@ export const gitApi = {
|
||||
const { data } = await api.post<GitRebaseResponse>("/git/rebase", request);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sweep a project's terminal-task branches (remote + local, PM/CEO only)
|
||||
*/
|
||||
cleanupBranches: async (
|
||||
request: GitBranchCleanupRequest,
|
||||
): Promise<GitBranchCleanupResponse> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
project_slug: request.project_slug,
|
||||
remote_deleted: 3,
|
||||
local_deleted: 3,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
truncated: false,
|
||||
next_cursor: null,
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<GitBranchCleanupResponse>(
|
||||
"/git/branches/cleanup",
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -198,3 +198,19 @@ export interface GitRebaseResponse {
|
||||
conflict: boolean;
|
||||
conflicted_files: string[];
|
||||
}
|
||||
|
||||
export interface GitBranchCleanupRequest {
|
||||
project_slug: string;
|
||||
/** Resume point from a prior truncated sweep's next_cursor. */
|
||||
after_cursor?: string;
|
||||
}
|
||||
|
||||
export interface GitBranchCleanupResponse {
|
||||
project_slug: string;
|
||||
remote_deleted: number;
|
||||
local_deleted: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
truncated: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user