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:
+181
-124
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user