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
@@ -56,8 +56,10 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { workspaceHref } from "../SessionRowMenu";
import { api } from "../../lib/api";
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
import { formatDateTime, formatDuration, formatModelName } from "../../lib/format";
import type {
SessionDrillIn as SessionDrillInData,
DashboardEvent,
@@ -157,14 +159,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
const { t } = useTranslation(["workflows", "common"]);
const indentPx = depth * 20;
const isMain = node.type === "main";
const dur = node.ended_at
? formatMs(
Math.max(
0,
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
)
)
: t("common:running");
const dur = node.ended_at ? formatDuration(node.started_at, node.ended_at) : t("common:running");
const sc = statusColor(node.status);
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
@@ -451,14 +446,27 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 flex-shrink-0">
<Link
to={`/sessions/${encodeURIComponent(session.id)}`}
className="btn-ghost text-xs px-2 py-1"
>
{t("drillIn.openSession")}
</Link>
{session.lane_id != null && (
<Link to={workspaceHref(session)!} className="btn-ghost text-xs px-2 py-1">
{t("drillIn.openInWorkspace")}
</Link>
)}
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<TabBar active={activeTab} onChange={onTabChange} />
</div>
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [sessions, setSessions] = useState<Session[]>([]);
const [allSessions, setAllSessions] = useState<Session[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [allLoaded, setAllLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Typing fires overlapping requests; only the newest may write the list.
const seqRef = useRef(0);
const fetchPage = useCallback((pageOffset: number, replace: boolean) => {
// The search runs on the server (`q` matches id, name and folder), one page
// at a time — never a download of every session to filter here.
const fetchPage = useCallback((q: string, pageOffset: number, replace: boolean) => {
const seq = ++seqRef.current;
setLoading(true);
api.sessions
.list({ limit: PAGE_SIZE, offset: pageOffset })
.list({ q: q || undefined, limit: PAGE_SIZE, offset: pageOffset })
.then(({ sessions: page }) => {
if (seq !== seqRef.current) return;
setSessions((prev) => (replace ? page : [...prev, ...page]));
setHasMore(page.length === PAGE_SIZE);
setOffset(pageOffset + page.length);
})
.catch(() => {})
.finally(() => setLoading(false));
.finally(() => {
if (seq === seqRef.current) setLoading(false);
});
}, []);
// Load ALL sessions for search (once, lazily)
const loadAllSessions = useCallback(() => {
if (allLoaded) return;
setAllLoaded(true);
// Fetch large batch for search
api.sessions
.list({ limit: 5000, offset: 0 })
.then(({ sessions: all }) => setAllSessions(all))
.catch(() => {});
}, [allLoaded]);
// Load first page when dropdown opens
// (Re)load the first page while open, debounced on the search text.
const query = search.trim();
useEffect(() => {
if (open && sessions.length === 0) {
fetchPage(0, true);
}
}, [open, sessions.length, fetchPage]);
// When user starts typing, load all sessions for search
useEffect(() => {
if (search.trim().length > 0) {
loadAllSessions();
}
}, [search, loadAllSessions]);
if (!open) return;
const timer = setTimeout(() => fetchPage(query, 0, true), query ? 250 : 0);
return () => clearTimeout(timer);
}, [open, query, fetchPage]);
// Close on outside click
useEffect(() => {
@@ -536,13 +533,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// When searching, filter across ALL sessions; otherwise show paginated
const filtered = search.trim()
? (allSessions.length > 0 ? allSessions : sessions).filter((s) => {
const q = search.toLowerCase();
return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
})
: sessions;
const filtered = sessions;
function handleSelect(id: string) {
setOpen(false);
@@ -551,7 +542,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
}
function handleLoadMore() {
fetchPage(offset, false);
fetchPage(query, offset, false);
}
return (
@@ -560,7 +551,8 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
<div
className={[
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
"focus-within:ring-2 focus-within:ring-accent/40",
open ? "border-accent/40" : "border-border",
].join(" ")}
onClick={() => {
setOpen(true);
@@ -573,6 +565,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="text"
value={search}
placeholder={t("drillIn.searchPlaceholder")}
aria-label={t("drillIn.searchPlaceholder")}
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
onFocus={() => setOpen(true)}
onChange={(e) => {
@@ -648,8 +641,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
</div>
)}
{/* Load more - only show when not filtering client-side */}
{!search.trim() && hasMore && (
{hasMore && (
<button
type="button"
onClick={handleLoadMore}