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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user