feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers

- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy
  (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale
  chunk after a self-update reloads once (vite:preloadError too), any other
  error shows a Reload panel instead of a blank page.
- Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing
  debounces that froze under a steady event stream; refresh skips hidden
  tabs and refetches after a WebSocket reconnect. Monitor loads only while
  shown, with one batched GET /api/agents?session_ids= instead of N+1; a
  failing endpoint is an inline error, not a blank Dashboard.
- NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort
  and search live in the URL (Dashboard, Sessions, Activity, SessionDetail).
- Sessions: tool / workspace / date-range filters, CSV/JSON export
  (formula-neutralised), focusable row links, Open in workspace (the dead
  /run links are gone), stale responses dropped, error + Retry.
- Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday
  labels west of UTC (now averages), cache hit rate denominator, billable
  tokens vs cache reads, exact tool-call counts, "To" date = end of day,
  invalid ?from= no longer throws, recent list no longer shrinks to 15,
  stable message keys, transcript switch races.
- Light-theme-unreadable bg-black code panels and dark-only DAG fills use
  tokens; status configs and chart colours use theme tokens; Health tab
  and other literals translated.
- Server (additive): GET /api/agents?session_ids=, GET /api/sessions
  awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event
  rows carry session/agent labels.
This commit is contained in:
2026-10-05 13:22:04 +07:00
parent 8fb703c6db
commit 03f1673fce
63 changed files with 3879 additions and 1662 deletions
+79 -11
View File
@@ -40,9 +40,9 @@
* - `./components/SplashScreen`
* - `./pages/Dashboard`
* - `./pages/History`
* - `./pages/SessionDetail`
* - `./pages/SettingsHub`
* - `./pages/Workspace`
* - `./pages/SessionDetail` (lazy)
* - `./pages/SettingsHub` (lazy)
* - `./pages/Workspace` (lazy)
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
@@ -54,23 +54,57 @@
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useCallback } from "react";
import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom";
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Layout } from "./components/Layout";
import { DocumentTitle } from "./components/DocumentTitle";
import { SplashScreen } from "./components/SplashScreen";
import { GlobalTooltip } from "./components/Tooltip";
import { Toaster } from "./components/Toaster";
import { ConfirmHost } from "./components/ConfirmHost";
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History";
import { SessionDetail } from "./pages/SessionDetail";
import { SettingsHub } from "./pages/SettingsHub";
import { Workspace } from "./pages/Workspace";
import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
import { eventBus } from "./lib/eventBus";
import type { WSMessage } from "./lib/types";
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
// visit instead of in the initial bundle.
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
const SettingsHub = lazy(() =>
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
);
const SessionDetail = lazy(() =>
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
);
function PageFallback() {
const { t } = useTranslation();
return (
<div role="status" className="flex items-center justify-center py-20 text-sm text-fg-muted">
{t("common:loading")}
</div>
);
}
function Page({ children }: { children: ReactNode }) {
return (
<RouteErrorBoundary>
<Suspense fallback={<PageFallback />}>{children}</Suspense>
</RouteErrorBoundary>
);
}
/** Keyed by the id so moving to another session starts from fresh state. */
function SessionDetailRoute() {
const { id } = useParams();
return <SessionDetail key={id} />;
}
/**
* Application root component mounted by {@link main.tsx}.
* @returns Routed dashboard UI inside `BrowserRouter`.
@@ -83,19 +117,39 @@ export default function App() {
const { connected } = useWebSocket(onMessage);
useNotifications();
// A self-update replaced the hashed assets this tab still references: Vite
// reports the failed preload here; reload once (guarded against loops).
useEffect(() => {
const onPreloadError = (e: Event) => {
if (reloadOnce()) e.preventDefault();
};
window.addEventListener("vite:preloadError", onPreloadError);
return () => window.removeEventListener("vite:preloadError", onPreloadError);
}, []);
return (
<>
<SplashScreen />
{/* One renderer gives every `title` in the app the designed bubble —
see components/Tooltip.tsx for why it is not a wrapper component. */}
<GlobalTooltip />
{/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */}
<Toaster />
<ConfirmHost />
<BrowserRouter>
<DocumentTitle />
<Routes>
<Route element={<Layout wsConnected={connected} />}>
<Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route
path="sessions/:id"
element={
<Page>
<SessionDetailRoute />
</Page>
}
/>
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route
path="workflows"
@@ -107,8 +161,22 @@ export default function App() {
element={<Navigate to="/settings/agents/claude-code" replace />}
/>
<Route path="run" element={<Navigate to="/workspace" replace />} />
<Route path="workspace" element={<Workspace />} />
<Route path="settings/*" element={<SettingsHub />} />
<Route
path="workspace"
element={
<Page>
<Workspace />
</Page>
}
/>
<Route
path="settings/*"
element={
<Page>
<SettingsHub />
</Page>
}
/>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>