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
@@ -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>
);
}