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:
Renzo F
2026-07-18 00:44:48 +02:00
committed by GitHub
co-authored by Renn F
parent 885d6bbe83
commit 496c24d186
39 changed files with 1841 additions and 222 deletions
+96 -2
View File
@@ -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">
+4
View File
@@ -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"));
});
});
+38 -1
View File
@@ -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,
};
}
+25
View File
@@ -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,
};
}
+26
View File
@@ -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;
},
};
+16
View File
@@ -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;
}