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
+181 -124
View File
@@ -57,7 +57,7 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import { Link, useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
@@ -77,9 +77,14 @@ import {
ExternalLink,
Workflow,
Hourglass,
X,
Download,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { workspaceHref } from "../components/SessionRowMenu";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { AgentCard } from "../components/AgentCard";
import { SessionOverview } from "../components/SessionOverview";
@@ -97,9 +102,10 @@ import {
import { EventDetail } from "../components/EventDetail";
import {
EventFilters,
EMPTY_FILTERS,
isEmptyFilters,
expandStatusToEventTypes,
filtersFromParams,
filtersToParams,
} from "../components/EventFilters";
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
@@ -119,6 +125,8 @@ import {
fmtCostFull,
timeAgo,
formatModelName,
parseDate,
isoOrUndefined,
} from "../lib/format";
import type {
Session,
@@ -127,6 +135,7 @@ import type {
CostResult,
TranscriptInfo,
WorkflowRun,
WSMessage,
} from "../lib/types";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
@@ -136,7 +145,8 @@ const EVENTS_INITIAL_BATCH = 50;
const EVENTS_MORE_BATCH = 500;
// Live-refresh bounds - see ActivityFeed for rationale.
const EVENTS_MAX_REFRESH = 500;
const EVENTS_REFRESH_DEBOUNCE_MS = 500;
const EVENTS_REFRESH_THROTTLE_MS = 1000;
const DETAIL_TABS: DetailTab[] = ["agents", "conversation", "timeline"];
export function SessionDetail() {
const { id } = useParams<{ id: string }>();
@@ -149,17 +159,41 @@ export function SessionDetail() {
const [events, setEvents] = useState<DashboardEvent[]>([]);
const [eventsTotal, setEventsTotal] = useState(0);
const [eventsLoadingMore, setEventsLoadingMore] = useState(false);
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
// Tab and timeline filters live in the URL (replace), so a reload or a
// shared link reopens the same view.
const [params, setParams] = useSearchParams();
const paramKey = params.toString();
const filters = useMemo(() => filtersFromParams(new URLSearchParams(paramKey)), [paramKey]);
const setFilters = useCallback(
(f: EventFiltersValue) => setParams((prev) => filtersToParams(f, prev), { replace: true }),
[setParams]
);
const [cost, setCost] = useState<CostResult | null>(null);
const [loading, setLoading] = useState(true);
// True when this session is currently being driven by an in-flight Run
// handle on /run. Drives the "Open in Run page" banner up top.
const [isDashboardRun, setIsDashboardRun] = useState(false);
// Live dashboard runs: one holding THIS session drives the "open its pane" banner.
const [runs, setRuns] = useState<RunHandle[]>([]);
const isDashboardRun = runs.some((h) => h.sessionId === id && h.status === "running");
const [error, setError] = useState<string | null>(null);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(() => {
return new Set<string>();
});
const [activeTab, setActiveTab] = useState<DetailTab>("agents");
const tabParam = params.get("tab");
const activeTab: DetailTab = DETAIL_TABS.includes(tabParam as DetailTab)
? (tabParam as DetailTab)
: "agents";
const setActiveTab = useCallback(
(tab: DetailTab) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (tab === "agents") next.delete("tab");
else next.set("tab", tab);
return next;
},
{ replace: true }
),
[setParams]
);
// Keep tabs mounted once visited so switching between them doesn't unmount/
// remount their subtrees (which causes a perceptible flash on click).
const [visitedTabs, setVisitedTabs] = useState<Set<DetailTab>>(() => new Set(["agents"]));
@@ -184,8 +218,6 @@ export function SessionDetail() {
// Refs let the websocket handler access latest state without re-subscribing.
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
const eventsLoadedCountRef = useRef(0);
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
eventsLoadedCountRef.current = events.length;
const goBack = useCallback(() => {
const historyState =
@@ -208,34 +240,20 @@ export function SessionDetail() {
}, [transcriptNotFound]);
// Probe /api/run to see whether THIS session is currently being driven by
// an in-flight Run handle. If so, surface a banner so the user can hop
// back to /run instead of viewing this as a passive transcript.
// a dashboard pane. If so, a banner links straight to that pane.
const probeRuns = useCallback(() => {
// Defensive: tests mock the api module without a `run` namespace.
if (!api.run || typeof api.run.list !== "function") return;
api.run
.list()
.then((r) => setRuns(r.items))
.catch(() => undefined);
}, []);
useEffect(() => {
if (!id) return;
let cancelled = false;
const probe = () => {
// Defensive: tests mock the api module without a `run` namespace.
if (!api.run || typeof api.run.list !== "function") return;
api.run
.list()
.then((r) => {
if (cancelled) return;
const live = r.items.some((h) => h.sessionId === id && h.status === "running");
setIsDashboardRun(live);
})
.catch(() => undefined);
};
probe();
const t = setInterval(probe, 10000);
const unsub = eventBus.subscribe((msg) => {
if (msg.type === "run_status") probe();
});
return () => {
cancelled = true;
clearInterval(t);
unsub();
};
}, [id]);
probeRuns();
}, [probeRuns]);
// A pane starting/stopping re-broadcasts its lane (`lane_update`).
useLiveRefresh(probeRuns, { intervalMs: 10000, match: (msg) => msg.type === "lane_update" });
const load = useCallback(async () => {
if (!id) return;
@@ -350,7 +368,7 @@ export function SessionDetail() {
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
compactions.forEach((a, i) => {
const time = a.started_at
? new Date(a.started_at).toLocaleTimeString(undefined, {
? parseDate(a.started_at).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})
@@ -374,8 +392,8 @@ export function SessionDetail() {
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
q: filters.q || undefined,
from: filters.from ? new Date(filters.from).toISOString() : undefined,
to: filters.to ? new Date(filters.to).toISOString() : undefined,
from: isoOrUndefined(filters.from),
to: isoOrUndefined(filters.to),
};
}, [id, filters]);
@@ -497,52 +515,58 @@ export function SessionDetail() {
}
}, []);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg) => {
if (isRemoteDataRefreshMessage(msg)) {
load();
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
eventsRefreshTimerRef.current = setTimeout(() => {
eventsRefreshTimerRef.current = null;
refreshEventsWithPagination();
}, EVENTS_REFRESH_DEBOUNCE_MS);
return;
}
// Only this session's messages: every hook of every session broadcasts,
// and a reload per message re-read this page's whole session each time.
const d = msg.data as { id?: string; session_id?: string } | undefined;
const mine = d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
if (!mine) return;
if (
msg.type === "agent_created" ||
// Only this session's messages: every hook of every session broadcasts, and
// a reload per message re-read this page's whole session each time. Both
// refreshes are throttled (a steady stream still updates, never freezes) and
// re-run after a WebSocket reconnect.
const isMine = (msg: WSMessage) => {
const d = msg.data as { id?: string; session_id?: string } | undefined;
return d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
};
useLiveRefresh(load, {
match: (msg) =>
isRemoteDataRefreshMessage(msg) ||
((msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "session_updated"
) {
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
loadTimerRef.current = setTimeout(() => {
loadTimerRef.current = null;
load();
}, EVENTS_REFRESH_DEBOUNCE_MS);
msg.type === "session_updated") &&
isMine(msg)),
});
// A filter-aware refetch that preserves the current "Load more" size.
useLiveRefresh(refreshEventsWithPagination, {
throttleMs: EVENTS_REFRESH_THROTTLE_MS,
match: (msg) => isRemoteDataRefreshMessage(msg) || (msg.type === "new_event" && isMine(msg)),
});
async function exportEvents(kind: "csv" | "json") {
if (!eventApiParams) return;
try {
// The whole filtered timeline, a page at a time (the route caps a page at 500).
const all: DashboardEvent[] = [];
for (let offset = 0; ; offset += 500) {
const page = await api.events.list({ ...eventApiParams, limit: 500, offset });
all.push(...page.events);
if (page.events.length < 500 || all.length >= page.total) break;
}
if (msg.type === "new_event") {
// Debounce bursts into one filter-aware refetch that preserves the
// current "Load more" pagination size.
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
eventsRefreshTimerRef.current = setTimeout(() => {
eventsRefreshTimerRef.current = null;
refreshEventsWithPagination();
}, EVENTS_REFRESH_DEBOUNCE_MS);
}
});
return () => {
unsubscribe();
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
if (eventsRefreshTimerRef.current) {
clearTimeout(eventsRefreshTimerRef.current);
eventsRefreshTimerRef.current = null;
}
};
}, [id, load, refreshEventsWithPagination]);
const name = exportName(`session-${id?.slice(0, 8)}-events`, kind);
if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json");
else
downloadText(
name,
toCsv(all, [
"id",
"created_at",
"event_type",
"tool_name",
"agent_id",
"agent_name",
"summary",
]),
"text/csv"
);
} catch (err) {
toast.error(err);
}
}
if (loading) {
return (
@@ -596,14 +620,14 @@ export function SessionDetail() {
<div className="animate-fade-in space-y-8">
{/* Header */}
<div className="flex items-start gap-4">
<button onClick={goBack} className="btn-ghost mt-1">
<button onClick={goBack} className="btn-ghost mt-1" aria-label={t("detail.back")}>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold text-fg-primary">
<h1 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
</h1>
<SessionToolBadge tool={session.tool} />
<SessionStatusBadge
status={effectiveSessionStatus(session)}
@@ -647,8 +671,21 @@ export function SessionDetail() {
</div>
)}
</div>
{(() => {
const href = workspaceHref(session, runs);
return href ? (
<Link to={href} className="btn-ghost">
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
</Link>
) : null;
})()}
<SessionResumeButton session={session} />
<button onClick={load} className="btn-ghost">
<button
onClick={load}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
@@ -670,40 +707,34 @@ export function SessionDetail() {
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
urgent
? "border-status-warning/40 bg-status-warning/[0.08]"
: "border-yellow-500/25 bg-yellow-500/[0.05]"
: "border-status-warning/25 bg-status-warning/[0.05]"
}`}
>
<span
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
urgent
? "bg-status-warning/15 border-status-warning/30"
: "bg-yellow-500/10 border-yellow-500/25"
: "bg-status-warning/10 border-status-warning/25"
}`}
>
<ReasonIcon
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-status-warning"}`}
/>
</span>
<div className="flex-1 min-w-0">
<div
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-status-warning"}`}
>
{t("detail.waitingBanner.title")}
{cfg && (
<span
className={
urgent
? "text-status-warning/90"
: "text-yellow-700/80 dark:text-yellow-300/80"
}
>
<span className={urgent ? "text-status-warning/90" : "text-status-warning/80"}>
{" · "}
{t(cfg.labelKey)}
</span>
)}
</div>
<div
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-status-warning/70"}`}
>
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
</div>
@@ -711,12 +742,12 @@ export function SessionDetail() {
{session.awaiting_input_since && (
<span
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
urgent ? "text-status-warning/80" : "text-status-warning/80"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${
urgent ? "bg-status-warning" : "bg-yellow-400"
urgent ? "bg-status-warning" : "bg-status-warning"
}`}
aria-hidden="true"
/>
@@ -727,9 +758,9 @@ export function SessionDetail() {
);
})()}
{isDashboardRun && (
{isDashboardRun && workspaceHref(session, runs) && (
<Link
to={`/run?session=${encodeURIComponent(id || "")}`}
to={workspaceHref(session, runs)!}
className="flex items-center gap-3 rounded-lg border border-status-success/30 bg-status-success/[0.06] hover:bg-status-success/[0.12] hover:border-status-success/50 px-4 py-2.5 transition-colors group"
>
<span className="w-7 h-7 rounded-md bg-status-success/15 border border-status-success/30 inline-flex items-center justify-center flex-shrink-0">
@@ -737,13 +768,10 @@ export function SessionDetail() {
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-status-success">
{t("detail.dashboardRun.title", "This session is being driven from the Run page")}
{t("detail.dashboardRun.title")}
</div>
<div className="text-[11px] text-status-success/70">
{t(
"detail.dashboardRun.body",
"Send follow-ups, watch streaming output, or stop the run from there."
)}
{t("detail.dashboardRun.body")}
</div>
</div>
<ExternalLink className="w-4 h-4 text-status-success/70 group-hover:text-status-success flex-shrink-0" />
@@ -759,7 +787,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "agents"
? "border-violet-500 text-violet-700 dark:text-violet-400"
? "border-accent text-accent"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -773,7 +801,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "conversation"
? "border-violet-500 text-violet-700 dark:text-violet-400"
? "border-accent text-accent"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -787,7 +815,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "timeline"
? "border-violet-500 text-violet-700 dark:text-violet-400"
? "border-accent text-accent"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -804,8 +832,9 @@ export function SessionDetail() {
<button
onClick={() => setTranscriptNotFound(false)}
className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
aria-label={t("common:dismiss")}
>
<List className="w-3.5 h-3.5" />
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
@@ -817,7 +846,7 @@ export function SessionDetail() {
{workflows.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<Workflow className="w-3.5 h-3.5 text-syntax-keyword" />
{wfT("runs.sessionTitle")}
<span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3>
@@ -830,7 +859,7 @@ export function SessionDetail() {
) : (
<>
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<Bot className="w-3.5 h-3.5 text-syntax-keyword" />
{t("detail.agents")}
<span className="text-fg-muted font-mono">· {agents.length}</span>
</h3>
@@ -896,7 +925,11 @@ export function SessionDetail() {
<button
onClick={toggleExpanded}
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
aria-label={
isExpanded
? t("detail.collapseSubagents")
: t("detail.expandSubagents")
}
aria-expanded={isExpanded}
>
{isExpanded ? (
@@ -908,7 +941,7 @@ export function SessionDetail() {
)}
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
<GitBranch className="w-3 h-3 text-syntax-keyword flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
@@ -938,7 +971,7 @@ export function SessionDetail() {
{/* Recursive children (collapsible) */}
{hasChildren && isExpanded && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-violet-500/20 pl-3">
<div className="ml-6 mt-1 space-y-1 border-l-2 border-syntax-keyword/20 pl-3">
{children.map((child) =>
renderAgentNode(child, depth + 1, childAncestors)
)}
@@ -951,7 +984,7 @@ export function SessionDetail() {
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
className="ml-7 mt-1 text-[11px] text-syntax-keyword hover:text-fg-primary transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
</button>
@@ -1074,8 +1107,24 @@ export function SessionDetail() {
{visitedTabs.has("timeline") && (
<div hidden={activeTab !== "timeline"}>
<div className="mb-3">
<EventFiltersInfo />
<div className="mb-3 flex flex-wrap items-start gap-2">
<div className="flex-1 min-w-0">
<EventFiltersInfo />
</div>
<button
onClick={() => exportEvents("csv")}
className="btn-ghost text-xs"
disabled={eventsTotal === 0}
>
<Download className="w-3.5 h-3.5" /> {t("exportCsv")}
</button>
<button
onClick={() => exportEvents("json")}
className="btn-ghost text-xs"
disabled={eventsTotal === 0}
>
<Download className="w-3.5 h-3.5" /> {t("exportJson")}
</button>
</div>
<div className="mb-3">
<EventFilters
@@ -1096,7 +1145,15 @@ export function SessionDetail() {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
<div key={key}>
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}