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