feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)

Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+78 -2
View File
@@ -12,6 +12,11 @@
* written back on every toggle. Failures to access storage are swallowed so a
* private-browsing quota error never breaks the UI.
*
* ## Sidebar width
* The expanded sidebar's right edge drags (or takes ←/→) between
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
*
* ## Narrow viewports
* With no stored preference the sidebar starts collapsed below `lg` (see
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
@@ -79,6 +84,26 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar";
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
/** Wide enough for long workspace names on a large screen, no wider. */
export const SIDEBAR_MAX = 480;
/** A width inside the bounds, and at most 40% of the window. */
export function clampSidebar(px: number): number {
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
}
function loadWidth(): number {
try {
const n = Number(localStorage.getItem(WIDTH_KEY));
if (n) return clampSidebar(n);
} catch {}
return SIDEBAR_MIN;
}
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
export const DRAWER_QUERY = "(max-width: 767.98px)";
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const { pathname, search } = useLocation();
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
const [width, setWidth] = useState(loadWidth);
const [resizing, setResizing] = useState(false);
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
const saveWidth = useCallback((px: number) => {
const next = clampSidebar(px);
setWidth(next);
try {
localStorage.setItem(WIDTH_KEY, String(next));
} catch {}
}, []);
// A pick in the drawer navigates; the drawer has done its job.
useEffect(closeDrawer, [pathname, search, closeDrawer]);
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
onUpdateAvailableChange={setUpdateAvailable}
width={width}
resizing={resizing}
/>
{!drawer && !collapsed && (
<div
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={SIDEBAR_MIN}
aria-valuemax={SIDEBAR_MAX}
aria-label={t("resizeSidebar")}
title={t("resizeSidebar")}
data-testid="sidebar-resize"
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
resizing ? "bg-accent/60" : ""
}`}
style={{ left: width }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
setResizing(true);
}}
onPointerMove={(e) => {
if (resizing) setWidth(clampSidebar(e.clientX));
}}
onPointerUp={(e) => {
if (!resizing) return;
setResizing(false);
saveWidth(e.clientX);
}}
onPointerCancel={() => setResizing(false)}
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
}}
/>
)}
<main
id="main-content"
tabIndex={-1}
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
resizing ? "" : "transition-[margin-left,width]"
}`}
style={{
marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`,