-
+
{i + startLine}
{line}
@@ -274,7 +274,7 @@ export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
return no diff
;
}
return (
-
+
{hunks.map((hunk, i) => (
diff --git a/client/src/components/workflows/CompactionImpact.tsx b/client/src/components/workflows/CompactionImpact.tsx
index e4129951..109a885f 100644
--- a/client/src/components/workflows/CompactionImpact.tsx
+++ b/client/src/components/workflows/CompactionImpact.tsx
@@ -58,8 +58,11 @@
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import * as d3 from "d3";
import type { CompactionImpactData } from "../../lib/types";
+import { max, axisBottom, axisLeft, scaleBand, scaleLinear, select, type Selection } from "d3";
+
+// Named imports so Vite tree-shakes d3 down to what these charts use.
+const d3 = { axisBottom, axisLeft, max, scaleBand, scaleLinear, select };
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -122,11 +125,11 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
- grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "rgb(var(--accent))");
grad
.append("stop")
.attr("offset", "100%")
- .attr("stop-color", "#3730a3")
+ .attr("stop-color", "rgb(var(--accent-solid))")
.attr("stop-opacity", 0.7);
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
@@ -212,7 +215,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
const barH = innerH - by;
const bg = g.append("g");
- let bar: d3.Selection
| null = null;
+ let bar: Selection | null = null;
if (d.sessions > 0) {
bar = bg
@@ -254,7 +257,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("fill", "transparent")
.style("cursor", "pointer")
.on("mouseenter", (event: MouseEvent) => {
- if (bar) bar.attr("fill", "#a5b4fc");
+ if (bar) bar.attr("fill", "rgb(var(--accent-hover))");
opts.onHover(event, d);
})
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
@@ -305,6 +308,20 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
+ // Redraw on resize (the histogram reads its container's width when drawn),
+ // like the Session Complexity Scatter does.
+ const [width, setWidth] = useState(0);
+ useEffect(() => {
+ const el = svgRef.current?.parentElement;
+ if (!el) return;
+ const obs = new ResizeObserver((entries) => {
+ const w = entries[0]?.contentRect.width;
+ if (w) setWidth(Math.round(w));
+ });
+ obs.observe(el);
+ return () => obs.disconnect();
+ }, [hasData]);
+
useEffect(() => {
if (!svgRef.current || !hasData) return;
const histo = toHistogram(data.perSession);
@@ -324,7 +341,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
onLeave: () => setTip(null),
});
- }, [data, hasData, t]);
+ }, [data, hasData, t, width]);
if (!hasData) {
return (
diff --git a/client/src/components/workflows/ErrorPropagationMap.tsx b/client/src/components/workflows/ErrorPropagationMap.tsx
index 8ffd010a..85e86a5a 100644
--- a/client/src/components/workflows/ErrorPropagationMap.tsx
+++ b/client/src/components/workflows/ErrorPropagationMap.tsx
@@ -55,7 +55,12 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { ErrorPropagationData } from "../../lib/types";
-const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"];
+const DEPTH_COLORS = [
+ "rgb(var(--status-danger))",
+ "rgb(var(--syn-number))",
+ "rgb(var(--status-warning))",
+ "rgb(var(--syn-keyword))",
+];
// ── Component ─────────────────────────────────────────────────────────────────
@@ -240,7 +245,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
height="14"
viewBox="0 0 24 24"
fill="none"
- stroke="#f59e0b"
+ stroke="rgb(var(--status-warning))"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
diff --git a/client/src/components/workflows/LanePipelinePanel.tsx b/client/src/components/workflows/LanePipelinePanel.tsx
index 24558df6..c56ffe0b 100644
--- a/client/src/components/workflows/LanePipelinePanel.tsx
+++ b/client/src/components/workflows/LanePipelinePanel.tsx
@@ -14,7 +14,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
-import { eventBus } from "../../lib/eventBus";
+import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import type { PipelineStats } from "../../lib/types";
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
@@ -26,7 +26,12 @@ function pct(n: number, total: number): number {
* Per-node pass statistics for the selected pipeline template.
* @returns The panel element, or an empty-state hint when nothing was recorded.
*/
-export function LanePipelinePanel() {
+export function LanePipelinePanel({
+ onAvailability,
+}: {
+ /** Told whether any pipeline template is loaded (pipelines are dormant by default). */
+ onAvailability?: (hasPipelines: boolean) => void;
+} = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState(null);
const [selectedId, setSelectedId] = useState(null);
@@ -46,18 +51,14 @@ export function LanePipelinePanel() {
void refresh();
}, [refresh]);
- // Stages move as agents declare them; debounce so a burst costs one fetch.
+ // Stages move as agents declare them; throttled (not debounced) so a burst
+ // costs one fetch and a steady stream still refreshes every 3 s.
+ useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
+
+ const hasPipelines = !!data && data.pipelines.length > 0;
useEffect(() => {
- let timer: ReturnType;
- const unsub = eventBus.subscribe(() => {
- clearTimeout(timer);
- timer = setTimeout(() => void refresh(), 3000);
- });
- return () => {
- unsub();
- clearTimeout(timer);
- };
- }, [refresh]);
+ if (data) onAvailability?.(hasPipelines);
+ }, [data, hasPipelines, onAvailability]);
if (error) return {error}
;
if (!data) return null;
@@ -71,7 +72,7 @@ export function LanePipelinePanel() {
setLaneId(e.target.value ? Number(e.target.value) : null)}
>
@@ -113,7 +114,7 @@ export function LanePipelinePanel() {
setSelectedId(e.target.value)}
>
diff --git a/client/src/components/workflows/OrchestrationDAG.tsx b/client/src/components/workflows/OrchestrationDAG.tsx
index 6b397cea..166d2564 100644
--- a/client/src/components/workflows/OrchestrationDAG.tsx
+++ b/client/src/components/workflows/OrchestrationDAG.tsx
@@ -47,8 +47,11 @@
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
-import * as d3 from "d3";
import type { OrchestrationData } from "../../lib/types";
+import { extent, scaleLinear, select } from "d3";
+
+// Named imports so Vite tree-shakes d3 down to what these charts use.
+const d3 = { extent, scaleLinear, select };
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -104,52 +107,45 @@ const BADGE_H = 14;
// Layer labels are now computed via i18n inside the component
-const OUTCOME_COLORS: Record = {
- completed: { fill: "#052e16", stroke: "#16a34a", text: "#4ade80" },
- error: { fill: "#1f0808", stroke: "#dc2626", text: "#f87171" },
- abandoned: { fill: "#1c1a04", stroke: "#ca8a04", text: "#facc15" },
+// Every colour is a theme token (or a mix of one over the card surface), so
+// the DAG reads in light themes too — it used to be dark-only hex.
+const tok = (v: string) => `rgb(var(${v}))`;
+const mix = (v: string, pct: number) =>
+ `color-mix(in srgb, rgb(var(${v})) ${pct}%, rgb(var(--surface-1)))`;
+
+const KIND_VAR: Record = {
+ session: "--accent",
+ main: "--syn-builtin",
+ subagent: "--status-success",
+ nested: "--syn-property",
+ outcome: "--accent",
+};
+const OUTCOME_VAR: Record = {
+ completed: "--status-success",
+ error: "--status-danger",
+ abandoned: "--status-warning",
};
-const KIND_GRADIENTS: Record<
- DAGNode["kind"],
- { id: string; stops: Array<{ offset: string; color: string }> }
-> = {
- session: {
- id: "grad-session",
- stops: [
- { offset: "0%", color: "#312e81" },
- { offset: "100%", color: "#4338ca" },
- ],
- },
- main: {
- id: "grad-main",
- stops: [
- { offset: "0%", color: "#1e3a5f" },
- { offset: "100%", color: "#1d4ed8" },
- ],
- },
- subagent: {
- id: "grad-subagent",
- stops: [
- { offset: "0%", color: "#052e16" },
- { offset: "100%", color: "#166534" },
- ],
- },
- nested: {
- id: "grad-nested",
- stops: [
- { offset: "0%", color: "#134e4a" },
- { offset: "100%", color: "#0f766e" },
- ],
- },
- outcome: {
- id: "grad-outcome",
- stops: [
- { offset: "0%", color: "#1e1b4b" },
- { offset: "100%", color: "#4338ca" },
- ],
- },
-};
+const OUTCOME_COLORS: Record =
+ Object.fromEntries(
+ Object.entries(OUTCOME_VAR).map(([k, v]) => [
+ k,
+ { fill: mix(v, 15), stroke: tok(v), text: tok(v) },
+ ])
+ );
+
+const KIND_GRADIENTS = Object.fromEntries(
+ (Object.keys(KIND_VAR) as DAGNode["kind"][]).map((kind) => [
+ kind,
+ {
+ id: `grad-${kind}`,
+ stops: [
+ { offset: "0%", color: mix(KIND_VAR[kind], 10) },
+ { offset: "100%", color: mix(KIND_VAR[kind], 25) },
+ ],
+ },
+ ])
+) as Record }>;
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -168,7 +164,13 @@ function successRate(completed: number, total: number): string {
}
function outcomeColorSet(status: string) {
- return OUTCOME_COLORS[status] ?? { fill: "#1a1a28", stroke: "#363650", text: "#9ca3af" };
+ return (
+ OUTCOME_COLORS[status] ?? {
+ fill: tok("--surface-2"),
+ stroke: tok("--border"),
+ text: tok("--fg-muted"),
+ }
+ );
}
// ── Layout builder ────────────────────────────────────────────────────────────
@@ -641,7 +643,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
- .attr("stroke", "#6366f1")
+ .attr("stroke", tok("--accent"))
.attr("stroke-width", stroke + 2)
.attr("stroke-opacity", isZero ? 0 : 0.08)
.attr("filter", "url(#edge-glow)");
@@ -651,7 +653,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
- .attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
+ .attr("stroke", isZero ? "rgb(var(--border-light))" : tok("--accent"))
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
@@ -698,7 +700,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("height", (d) => d.height + 6)
.attr("rx", NODE_RX + 3)
.attr("fill", "none")
- .attr("stroke", "#6366f1")
+ .attr("stroke", tok("--accent"))
.attr("stroke-width", 2)
.attr("filter", "url(#glow)")
.attr("opacity", 0.8);
@@ -717,7 +719,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
return `url(#${KIND_GRADIENTS[d.kind].id})`;
})
.attr("stroke", (d) => {
- if (d.id === selectedNode) return "#6366f1";
+ if (d.id === selectedNode) return tok("--accent");
if (d.kind === "outcome" && d.meta?.status) {
return outcomeColorSet(d.meta.status).stroke;
}
@@ -831,18 +833,22 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
{t("orchestration.legend")}
{LEGEND_ITEMS.map((item) => (
-
+
- {item.label}
+
+ {t(`orchestration.legendItems.${item.key}`)}
+
))}
{t("orchestration.edgeWeight")}
@@ -1014,51 +1020,16 @@ function describeNode(node: DAGNode, t: TFn): { layer: string; description: stri
// ── Utility functions ─────────────────────────────────────────────────────────
function borderColorForKind(kind: DAGNode["kind"]): string {
- switch (kind) {
- case "session":
- return "#6366f1";
- case "main":
- return "#3b82f6";
- case "subagent":
- return "#22c55e";
- case "nested":
- return "#14b8a6";
- case "outcome":
- return "#6366f1";
- }
+ return tok(KIND_VAR[kind]);
}
function textColorForKind(kind: DAGNode["kind"]): string {
- switch (kind) {
- case "session":
- return "#a5b4fc";
- case "main":
- return "#93c5fd";
- case "subagent":
- return "#86efac";
- case "nested":
- return "#5eead4";
- case "outcome":
- return "#c4b5fd";
- }
+ return tok(KIND_VAR[kind]);
}
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
- if (kind === "outcome" && status) {
- return outcomeColorSet(status).stroke + "33";
- }
- switch (kind) {
- case "session":
- return "rgba(99,102,241,0.25)";
- case "main":
- return "rgba(59,130,246,0.25)";
- case "subagent":
- return "rgba(34,197,94,0.25)";
- case "nested":
- return "rgba(20,184,166,0.25)";
- case "outcome":
- return "rgba(99,102,241,0.25)";
- }
+ if (kind === "outcome" && status) return mix(OUTCOME_VAR[status] ?? "--border", 25);
+ return mix(KIND_VAR[kind], 25);
}
function fmtCount(n: number): string {
@@ -1070,11 +1041,11 @@ function fmtCount(n: number): string {
// ── Legend data ───────────────────────────────────────────────────────────────
const LEGEND_ITEMS = [
- { label: "Sessions", color: "#312e81", border: "#6366f1" },
- { label: "Main Agent", color: "#1e3a5f", border: "#3b82f6" },
- { label: "Subagent Types", color: "#052e16", border: "#22c55e" },
- { label: "Compactions", color: "#134e4a", border: "#14b8a6" },
- { label: "Completed", color: "#052e16", border: "#16a34a" },
- { label: "Error", color: "#1f0808", border: "#dc2626" },
- { label: "Abandoned", color: "#1c1a04", border: "#ca8a04" },
-];
+ { key: "sessions", v: "--accent" },
+ { key: "mainAgent", v: "--syn-builtin" },
+ { key: "subagentTypes", v: "--status-success" },
+ { key: "compactions", v: "--syn-property" },
+ { key: "completed", v: "--status-success" },
+ { key: "error", v: "--status-danger" },
+ { key: "abandoned", v: "--status-warning" },
+].map((i) => ({ ...i, color: mix(i.v, 20), border: tok(i.v) }));
diff --git a/client/src/components/workflows/SessionComplexityScatter.tsx b/client/src/components/workflows/SessionComplexityScatter.tsx
index b06c37e0..c8e1f776 100644
--- a/client/src/components/workflows/SessionComplexityScatter.tsx
+++ b/client/src/components/workflows/SessionComplexityScatter.tsx
@@ -54,9 +54,12 @@
import { useRef, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
-import * as d3 from "d3";
import type { SessionComplexityItem } from "../../lib/types";
import { formatModelName } from "../../lib/format";
+import { max, axisBottom, axisLeft, format, scaleLinear, scaleSqrt, select } from "d3";
+
+// Named imports so Vite tree-shakes d3 down to what these charts use.
+const d3 = { axisBottom, axisLeft, format, max, scaleLinear, scaleSqrt, select };
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -65,14 +68,14 @@ const MIN_BUBBLE_R = 4;
const MAX_BUBBLE_R = 32;
const STATUS_COLOR: Record
= {
- completed: "#22c55e",
- error: "#ef4444",
- active: "#6366f1",
- abandoned: "#eab308",
+ completed: "rgb(var(--status-success))",
+ error: "rgb(var(--status-danger))",
+ active: "rgb(var(--accent))",
+ abandoned: "rgb(var(--status-warning))",
};
function statusColor(status: string): string {
- return STATUS_COLOR[status] ?? "#6b7280";
+ return STATUS_COLOR[status] ?? "rgb(var(--fg-muted))";
}
// ── Duration formatting ───────────────────────────────────────────────────────
diff --git a/client/src/components/workflows/SessionDrillIn.tsx b/client/src/components/workflows/SessionDrillIn.tsx
index 6c168e22..170110c4 100644
--- a/client/src/components/workflows/SessionDrillIn.tsx
+++ b/client/src/components/workflows/SessionDrillIn.tsx
@@ -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)}`}
-
-
-
+
+
+ {t("drillIn.openSession")}
+
+ {session.lane_id != null && (
+
+ {t("drillIn.openInWorkspace")}
+
+ )}
+
+
+
+
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [sessions, setSessions] = useState
([]);
- const [allSessions, setAllSessions] = useState([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
- const [allLoaded, setAllLoaded] = useState(false);
const containerRef = useRef(null);
const inputRef = useRef(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) {
{
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) {
)}
- {/* Load more - only show when not filtering client-side */}
- {!search.trim() && hasMore && (
+ {hasMore && (
{
- let timer: ReturnType;
- const unsub = eventBus.subscribe(() => {
- clearTimeout(timer);
- timer = setTimeout(() => void refresh(), 3000);
- });
- return () => {
- unsub();
- clearTimeout(timer);
- };
- }, [refresh]);
+ // Throttled (not debounced): a steady event stream still refreshes every 3 s.
+ useLiveRefresh(() => void refresh(), {
+ match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
+ });
if (error) return {error}
;
if (!data) return null;
diff --git a/client/src/components/workflows/WorkflowRunsPanel.tsx b/client/src/components/workflows/WorkflowRunsPanel.tsx
index 66c52c20..35bfbdab 100644
--- a/client/src/components/workflows/WorkflowRunsPanel.tsx
+++ b/client/src/components/workflows/WorkflowRunsPanel.tsx
@@ -589,7 +589,7 @@ export function WorkflowRunsPanel({
{t("runs.promptLabel")}
-
+
{fullPrompt}
@@ -598,7 +598,7 @@ export function WorkflowRunsPanel({
{t("runs.resultLabel")}
-
+
{fullResult}
diff --git a/client/src/hooks/__tests__/useLiveRefresh.test.ts b/client/src/hooks/__tests__/useLiveRefresh.test.ts
new file mode 100644
index 00000000..f1233bd2
--- /dev/null
+++ b/client/src/hooks/__tests__/useLiveRefresh.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @file useLiveRefresh.test.ts
+ * @description Tests the shared live-refresh hook: matched WebSocket messages
+ * reload through a throttle, others are ignored, a reconnect reloads, and a
+ * disabled hook does nothing.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useLiveRefresh } from "../useLiveRefresh";
+import { eventBus } from "../../lib/eventBus";
+import type { WSMessage } from "../../lib/types";
+
+const msg = (type: WSMessage["type"]) => ({ type, data: {} }) as unknown as WSMessage;
+
+afterEach(() => {
+ vi.useRealTimers();
+ eventBus.setConnected(false);
+});
+
+describe("useLiveRefresh", () => {
+ it("throttles matched messages and ignores the rest", () => {
+ vi.useFakeTimers();
+ const load = vi.fn();
+ renderHook(() => useLiveRefresh(load, { match: (m) => m.type === "new_event" }));
+ eventBus.publish(msg("agent_updated"));
+ expect(load).not.toHaveBeenCalled();
+ for (let i = 0; i < 10; i++) eventBus.publish(msg("new_event"));
+ expect(load).toHaveBeenCalledTimes(1);
+ vi.advanceTimersByTime(3000);
+ expect(load).toHaveBeenCalledTimes(2);
+ });
+
+ it("reloads after the socket reconnects", () => {
+ const load = vi.fn();
+ eventBus.setConnected(true);
+ renderHook(() => useLiveRefresh(load));
+ eventBus.setConnected(false);
+ eventBus.setConnected(true);
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it("does nothing while disabled", () => {
+ const load = vi.fn();
+ renderHook(() => useLiveRefresh(load, { enabled: false, match: () => true }));
+ eventBus.publish(msg("new_event"));
+ expect(load).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/hooks/useLiveRefresh.ts b/client/src/hooks/useLiveRefresh.ts
new file mode 100644
index 00000000..41fbb56b
--- /dev/null
+++ b/client/src/hooks/useLiveRefresh.ts
@@ -0,0 +1,71 @@
+/**
+ * @file useLiveRefresh.ts
+ * @description Keeps a page's data fresh without hammering the server: re-runs
+ * `load` on matching WebSocket messages through a leading+trailing throttle (a
+ * steady event stream can never freeze or flood the page), on a poll that skips
+ * hidden tabs, when the tab becomes visible again, and after the socket
+ * reconnects (messages sent while it was down are lost). The caller still does
+ * its own initial load.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { useEffect, useRef } from "react";
+import { eventBus } from "../lib/eventBus";
+import { throttle } from "../lib/throttle";
+import type { WSMessage } from "../lib/types";
+
+export interface LiveRefreshOptions {
+ /** Which WebSocket messages warrant a reload; none when omitted. */
+ match?: (msg: WSMessage) => boolean;
+ /** Poll period while the tab is visible; no poll when omitted. */
+ intervalMs?: number;
+ /** Minimum gap between event-driven reloads (default 3 s). */
+ throttleMs?: number;
+ /** Off = no subscription, no poll (e.g. an inactive tab). Default true. */
+ enabled?: boolean;
+}
+
+export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}): void {
+ const { intervalMs, throttleMs = 3000, enabled = true } = opts;
+ const loadRef = useRef(load);
+ const matchRef = useRef(opts.match);
+ loadRef.current = load;
+ matchRef.current = opts.match;
+
+ useEffect(() => {
+ if (!enabled) return;
+ const live = throttle(() => loadRef.current(), throttleMs);
+ const visible = () => document.visibilityState === "visible";
+ const offMsg = eventBus.subscribe((msg) => {
+ if (matchRef.current?.(msg)) live();
+ });
+ let wasDown = !eventBus.connected;
+ // (Guarded: test doubles of the bus often stub `subscribe` only.)
+ const offConn =
+ typeof eventBus.onConnection === "function"
+ ? eventBus.onConnection((up) => {
+ if (!up) wasDown = true;
+ else if (wasDown) {
+ wasDown = false;
+ live();
+ }
+ })
+ : () => undefined;
+ const onVis = () => {
+ if (visible()) live();
+ };
+ document.addEventListener("visibilitychange", onVis);
+ const timer = intervalMs
+ ? setInterval(() => {
+ if (visible()) loadRef.current();
+ }, intervalMs)
+ : undefined;
+ return () => {
+ live.cancel();
+ offMsg();
+ offConn();
+ document.removeEventListener("visibilitychange", onVis);
+ clearInterval(timer);
+ };
+ }, [enabled, intervalMs, throttleMs]);
+}
diff --git a/client/src/i18n/locales/en/activity.json b/client/src/i18n/locales/en/activity.json
index 45abba78..c65f46e7 100644
--- a/client/src/i18n/locales/en/activity.json
+++ b/client/src/i18n/locales/en/activity.json
@@ -1,10 +1,17 @@
{
"title": "Activity Feed",
"subtitle": "Real-time stream of all agent events",
- "paused": "(paused - {{count}} buffered)",
+ "paused": "(paused — {{count}} new)",
"resume": "Resume",
"pause": "Pause",
"noActivity": "No activity yet",
"noActivityDesc": "Events will stream here in real-time as Claude Code agents work.",
- "viewSession": "Session"
+ "viewSession": "Session",
+ "exportCsv": "CSV",
+ "exportJson": "JSON",
+ "failedLoad": "Failed to load events",
+ "newEvents_one": "{{count}} new event — back to the latest",
+ "newEvents_other": "{{count}} new events — back to the latest",
+ "firstPage": "First page",
+ "lastPage": "Last page"
}
diff --git a/client/src/i18n/locales/en/analytics.json b/client/src/i18n/locales/en/analytics.json
index e3447798..132bba79 100644
--- a/client/src/i18n/locales/en/analytics.json
+++ b/client/src/i18n/locales/en/analytics.json
@@ -50,5 +50,8 @@
"noSessionTrendData": "No session trend data yet.",
"last7Days": "Last 7 days",
"less": "Less",
- "more": "More"
+ "more": "More",
+ "tokensExCacheReads": "Tokens (excl. cache reads)",
+ "cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
+ "avgPerWeekday30d": "Average per weekday, last 30 days"
}
diff --git a/client/src/i18n/locales/en/dashboard.json b/client/src/i18n/locales/en/dashboard.json
index 531609e7..f8cf6d4f 100644
--- a/client/src/i18n/locales/en/dashboard.json
+++ b/client/src/i18n/locales/en/dashboard.json
@@ -18,5 +18,94 @@
"failedConnect": "Failed to connect to server",
"failedLoad": "Failed to load data",
"activeTrend": " active",
- "totalTrend": " in active sessions"
+ "totalTrend": " in active sessions",
+ "tabs": {
+ "label": "Dashboard views",
+ "analytics": "Analytics",
+ "monitor": "Monitor",
+ "health": "Health"
+ },
+ "collapseSubagents": "Collapse subagents",
+ "expandSubagents": "Expand subagents",
+ "needsYou": {
+ "title": "Needs you",
+ "since": "waiting {{ago}}"
+ },
+ "health": {
+ "runtime": "Runtime",
+ "cores_one": "{{count}} core",
+ "cores_other": "{{count}} cores",
+ "uptime": "Uptime",
+ "cpu": "CPU (1/5/15m)",
+ "nodeRss": "Node RSS",
+ "hostMemory": "Host Memory",
+ "hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}",
+ "heap": "V8 Heap",
+ "heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
+ "storage": "Storage",
+ "writeVelocityTip": "Write velocity (events):\n5 min: {{m5}}\n15 min: {{m15}}\n1 hr: {{h1}}",
+ "database": "Database",
+ "recordsTip": "Record distribution\n\nSessions: {{sessions}} ({{sessPct}}%)\nAgents: {{agents}} ({{agentPct}}%)\nEvents: {{events}} ({{eventPct}}%)\n\nTotal: {{total}} records",
+ "total": "total",
+ "sessions": "Sessions",
+ "agents": "Agents",
+ "events": "Events",
+ "score": "Health Score",
+ "formula": "Formula",
+ "formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
+ "scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%",
+ "cache": "Cache",
+ "cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}",
+ "errors": "Errors",
+ "errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical",
+ "compact": "Compact",
+ "compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
+ "saved": "Saved",
+ "savedTip": "Tokens recovered: {{tokens}}\nFreed by compaction.",
+ "tokenUsage": "Token Usage",
+ "totalSuffix": "{{value}} total",
+ "modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
+ "noModelData": "No model data",
+ "concurrency": "Concurrency",
+ "intervals_one": "{{count}} interval",
+ "intervals_other": "{{count}} intervals",
+ "concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
+ "noConcurrencyData": "No concurrency data",
+ "peak": "Peak",
+ "peakTip": "Peak: {{count}} sessions running simultaneously.",
+ "active": "Active",
+ "activeTip": "{{active}} of {{total}} intervals have active sessions.",
+ "avg": "Avg",
+ "avgTip": "Average concurrency across all intervals.",
+ "toolUsage": "Tool Usage",
+ "top": "top {{count}}",
+ "invocationsTip_one": "{{tool}}: {{formatted}} invocation",
+ "invocationsTip_other": "{{tool}}: {{formatted}} invocations",
+ "noToolData": "No tool data yet",
+ "subagentEffectiveness": "Subagent Effectiveness",
+ "defaultType": "default",
+ "effectivenessTip": "{{type}}\nSuccess: {{pct}}%\nTotal: {{total}} · OK: {{ok}} · Errors: {{errors}}",
+ "noSubagentData": "No subagent data yet",
+ "integration": "Integration",
+ "hooksActive": "Active",
+ "hookTip": "Path: {{path}}\nStatus: {{status}}",
+ "connected": "Connected",
+ "disconnected": "Disconnected",
+ "noHooks": "No project hooks registered",
+ "wsTip": "WebSocket connections: {{count}}\nProtocol: RFC 6455",
+ "wsActive": "WebSocket active",
+ "connections_one": "{{count}} connection",
+ "connections_other": "{{count}} connections",
+ "platform": "Platform",
+ "journalMode": "Journal mode",
+ "synchronous": "Synchronous",
+ "autoVacuum": "Auto-vacuum",
+ "foreignKeys": "Foreign keys",
+ "busyTimeout": "Busy timeout"
+ },
+ "errorBoundary": {
+ "title": "Something went wrong",
+ "body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
+ "reload": "Reload"
+ }
}
diff --git a/client/src/i18n/locales/en/sessions.json b/client/src/i18n/locales/en/sessions.json
index 36006c42..9d8fbef2 100644
--- a/client/src/i18n/locales/en/sessions.json
+++ b/client/src/i18n/locales/en/sessions.json
@@ -37,7 +37,14 @@
"noEvents": "No events recorded.",
"conversation": "Conversation",
"timeline": "Timeline ({{shown}}/{{total}})",
- "transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
+ "transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked.",
+ "back": "Back",
+ "collapseSubagents": "Collapse subagents",
+ "expandSubagents": "Expand subagents",
+ "dashboardRun": {
+ "title": "A dashboard pane is running this session",
+ "body": "Open its workspace pane to send follow-ups, watch the output or stop it."
+ }
},
"remoteSourceBadgeTitle": "Collected from a remote machine",
"toolBadgeTitle": "Recorded by {{tool}}",
@@ -46,5 +53,108 @@
"inWorkspace": "Open in its workspace",
"copyId": "Copy session ID",
"copyFolder": "Copy folder path"
+ },
+ "exportCsv": "CSV",
+ "exportJson": "JSON",
+ "filterDirectory": "Filter by directory",
+ "allDirectories": "All directories",
+ "sortLabel": "Sort sessions",
+ "sortTimeDesc": "Sort by time (newest)",
+ "sortTimeAsc": "Sort by time (oldest)",
+ "sortDurationDesc": "Sort by duration (longest)",
+ "sortDurationAsc": "Sort by duration (shortest)",
+ "sortPriceDesc": "Sort by price (highest)",
+ "sortPriceAsc": "Sort by price (lowest)",
+ "descending": "Descending",
+ "ascending": "Ascending",
+ "filterTool": "Filter by AI tool",
+ "allTools": "All AI tools",
+ "filterWorkspace": "Filter by workspace",
+ "allWorkspaces": "All workspaces",
+ "dateFrom": "From",
+ "dateTo": "To",
+ "clearFilters": "Clear filters",
+ "failedList": "Failed to load sessions",
+ "tableRegion": "Sessions table",
+ "openInWorkspace": "Open in workspace",
+ "openInWorkspaceTitle": "A dashboard pane is running this session — open it",
+ "datePicker": {
+ "placeholder": "Select date & time",
+ "clear": "Clear date",
+ "prevMonth": "Previous month",
+ "nextMonth": "Next month",
+ "time": "Time"
+ },
+ "conversation": {
+ "failedLoad": "Failed to load transcript",
+ "transcript": "Transcript",
+ "messages_one": "{{count}} message",
+ "messages_other": "{{count}} messages",
+ "refresh": "Refresh conversation",
+ "loadingHistory": "Loading history…",
+ "scrollForOlder": "Scroll up for older messages",
+ "loading": "Loading conversation…",
+ "empty": "No conversation records found.",
+ "emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.",
+ "newMessages": "New messages",
+ "systemReminder": "System reminder",
+ "persistedOutput": "Persisted output",
+ "taskNotification": "Task notification",
+ "thinking": "Thinking",
+ "chars_one": "{{formatted}} char",
+ "chars_other": "{{formatted}} chars",
+ "sender": {
+ "user": "User",
+ "assistant": "Assistant",
+ "orchestrator": "Main agent",
+ "system": "System",
+ "tool": "Tool"
+ },
+ "lines_one": "{{count}} line",
+ "lines_other": "{{count}} lines",
+ "copyCode": "Copy code",
+ "copy": "Copy",
+ "tool": {
+ "inputTruncated": "Input (truncated)",
+ "command": "Command",
+ "newFile": "New file",
+ "replaceAll": "replace all",
+ "removed": "Removed",
+ "added": "Added",
+ "pattern": "Pattern",
+ "path": "Path",
+ "glob": "Glob",
+ "input": "Input",
+ "empty": "empty",
+ "error": "Error",
+ "output": "Output",
+ "statusError": "error",
+ "statusOk": "ok",
+ "statusPending": "pending"
+ }
+ },
+ "overview": {
+ "agent": "Agent",
+ "running": "running",
+ "events": "Events",
+ "perMin": "{{rate}}/min",
+ "toolCalls": "Tool calls",
+ "unique": "{{count}} unique",
+ "subagents": "Subagents",
+ "plusMain_one": "+{{count}} main",
+ "plusMain_other": "+{{count}} main",
+ "compactions": "Compactions",
+ "errors": "Errors",
+ "duration": "Duration",
+ "topTools": "Top tools",
+ "distinctTotal": "{{count}} total",
+ "noToolCalls": "No tool calls yet.",
+ "contextCompaction": "Context Compaction",
+ "runs_one": "{{count}} run",
+ "runs_other": "{{count}} runs",
+ "noSubagents": "No subagents in this session.",
+ "tokenFlow": "Token flow",
+ "total": "{{value}} total",
+ "eventMix": "Event mix"
}
}
diff --git a/client/src/i18n/locales/en/workflows.json b/client/src/i18n/locales/en/workflows.json
index 13ee7eee..73ff62ef 100644
--- a/client/src/i18n/locales/en/workflows.json
+++ b/client/src/i18n/locales/en/workflows.json
@@ -204,6 +204,15 @@
"outcomeAbandoned": "Sessions that stopped without a clean Stop event and without explicit errors - usually closed mid-flight.",
"outcomeFallback": "The terminal status of sessions in scope, computed from the last hook event observed for each session."
}
+ },
+ "legendItems": {
+ "sessions": "Sessions",
+ "mainAgent": "Main agent",
+ "subagentTypes": "Subagent types",
+ "compactions": "Compactions",
+ "completed": "Completed",
+ "error": "Error",
+ "abandoned": "Abandoned"
}
},
"drillIn": {
@@ -230,7 +239,9 @@
"noEvents": "No events recorded for this session.",
"showingOf": "Showing 100 of {{total}} events",
"sessionInfo": " session(s) - active {{from}}%-{{to}}% of session",
- "sortedByCompaction": " session(s) (sorted by compaction count)"
+ "sortedByCompaction": " session(s) (sorted by compaction count)",
+ "openSession": "Open session",
+ "openInWorkspace": "Open in workspace"
},
"lanes": {
"title": "Workspace Pipeline Pass Rates",
@@ -316,5 +327,7 @@
"sessions_one": "{{count}} session",
"sessions_other": "{{count}} sessions",
"pluginBreakdown": "{{skills}} skill(s) used · {{skillCalls}} skill call(s) · {{mcpCalls}} MCP tool call(s)"
- }
+ },
+ "selectedNode": "Selected:",
+ "clearSelection": "Clear selection"
}
diff --git a/client/src/i18n/locales/vi/activity.json b/client/src/i18n/locales/vi/activity.json
index 8cb35dde..c7dd4844 100644
--- a/client/src/i18n/locales/vi/activity.json
+++ b/client/src/i18n/locales/vi/activity.json
@@ -1,10 +1,17 @@
{
"title": "Luồng hoạt động",
"subtitle": "Luồng thời gian thực của mọi sự kiện Agent",
- "paused": "(đã tạm dừng - {{count}} mục đệm)",
+ "paused": "(đã tạm dừng — {{count}} sự kiện mới)",
"resume": "Tiếp tục",
"pause": "Tạm dừng",
"noActivity": "Chưa có hoạt động",
"noActivityDesc": "Sự kiện sẽ được phát trực tiếp tại đây khi các Agent Claude Code làm việc.",
- "viewSession": "Phiên"
+ "viewSession": "Phiên",
+ "exportCsv": "CSV",
+ "exportJson": "JSON",
+ "failedLoad": "Không thể tải sự kiện",
+ "newEvents_one": "{{count}} sự kiện mới — về mới nhất",
+ "newEvents_other": "{{count}} sự kiện mới — về mới nhất",
+ "firstPage": "Trang đầu",
+ "lastPage": "Trang cuối"
}
diff --git a/client/src/i18n/locales/vi/analytics.json b/client/src/i18n/locales/vi/analytics.json
index 1dfdc98c..18061250 100644
--- a/client/src/i18n/locales/vi/analytics.json
+++ b/client/src/i18n/locales/vi/analytics.json
@@ -50,5 +50,8 @@
"noSessionTrendData": "Chưa có dữ liệu xu hướng phiên.",
"last7Days": "7 ngày gần nhất",
"less": "Ít",
- "more": "Nhiều"
+ "more": "Nhiều",
+ "tokensExCacheReads": "Token (trừ đọc cache)",
+ "cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%",
+ "avgPerWeekday30d": "Trung bình theo thứ trong tuần, 30 ngày qua"
}
diff --git a/client/src/i18n/locales/vi/dashboard.json b/client/src/i18n/locales/vi/dashboard.json
index 6ed33068..41111b43 100644
--- a/client/src/i18n/locales/vi/dashboard.json
+++ b/client/src/i18n/locales/vi/dashboard.json
@@ -18,5 +18,94 @@
"failedConnect": "Không thể kết nối máy chủ",
"failedLoad": "Không thể tải dữ liệu",
"activeTrend": " đang hoạt động",
- "totalTrend": " trong phiên hoạt động"
+ "totalTrend": " trong phiên hoạt động",
+ "tabs": {
+ "label": "Các chế độ xem tổng quan",
+ "analytics": "Phân tích",
+ "monitor": "Giám sát",
+ "health": "Sức khỏe hệ thống"
+ },
+ "collapseSubagents": "Thu gọn subagent",
+ "expandSubagents": "Mở rộng subagent",
+ "needsYou": {
+ "title": "Cần bạn xử lý",
+ "since": "đang chờ {{ago}}"
+ },
+ "health": {
+ "runtime": "Môi trường chạy",
+ "cores_one": "{{count}} lõi",
+ "cores_other": "{{count}} lõi",
+ "uptime": "Thời gian chạy",
+ "cpu": "CPU (1/5/15 phút)",
+ "nodeRss": "Bộ nhớ Node (RSS)",
+ "hostMemory": "Bộ nhớ máy",
+ "hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}",
+ "heap": "Heap V8",
+ "heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
+ "storage": "Lưu trữ",
+ "writeVelocityTip": "Tốc độ ghi (sự kiện):\n5 phút: {{m5}}\n15 phút: {{m15}}\n1 giờ: {{h1}}",
+ "database": "Cơ sở dữ liệu",
+ "recordsTip": "Phân bố bản ghi\n\nPhiên: {{sessions}} ({{sessPct}}%)\nAgent: {{agents}} ({{agentPct}}%)\nSự kiện: {{events}} ({{eventPct}}%)\n\nTổng: {{total}} bản ghi",
+ "total": "tổng",
+ "sessions": "Phiên",
+ "agents": "Agent",
+ "events": "Sự kiện",
+ "score": "Điểm sức khỏe",
+ "formula": "Công thức",
+ "formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
+ "scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%",
+ "cache": "Cache",
+ "cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}",
+ "errors": "Lỗi",
+ "errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng",
+ "compact": "Nén",
+ "compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
+ "saved": "Tiết kiệm",
+ "savedTip": "Token thu hồi: {{tokens}}\nĐược giải phóng nhờ nén.",
+ "tokenUsage": "Sử dụng token",
+ "totalSuffix": "tổng {{value}}",
+ "modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
+ "noModelData": "Chưa có dữ liệu mô hình",
+ "concurrency": "Đồng thời",
+ "intervals_one": "{{count}} khoảng",
+ "intervals_other": "{{count}} khoảng",
+ "concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
+ "noConcurrencyData": "Chưa có dữ liệu đồng thời",
+ "peak": "Đỉnh",
+ "peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
+ "active": "Hoạt động",
+ "activeTip": "{{active}} trên {{total}} khoảng có phiên hoạt động.",
+ "avg": "TB",
+ "avgTip": "Mức đồng thời trung bình trên mọi khoảng.",
+ "toolUsage": "Sử dụng công cụ",
+ "top": "top {{count}}",
+ "invocationsTip_one": "{{tool}}: {{formatted}} lần gọi",
+ "invocationsTip_other": "{{tool}}: {{formatted}} lần gọi",
+ "noToolData": "Chưa có dữ liệu công cụ",
+ "subagentEffectiveness": "Hiệu quả subagent",
+ "defaultType": "mặc định",
+ "effectivenessTip": "{{type}}\nThành công: {{pct}}%\nTổng: {{total}} · OK: {{ok}} · Lỗi: {{errors}}",
+ "noSubagentData": "Chưa có dữ liệu subagent",
+ "integration": "Tích hợp",
+ "hooksActive": "Đang hoạt động",
+ "hookTip": "Đường dẫn: {{path}}\nTrạng thái: {{status}}",
+ "connected": "Đã kết nối",
+ "disconnected": "Mất kết nối",
+ "noHooks": "Chưa đăng ký hook dự án nào",
+ "wsTip": "Kết nối WebSocket: {{count}}\nGiao thức: RFC 6455",
+ "wsActive": "WebSocket đang hoạt động",
+ "connections_one": "{{count}} kết nối",
+ "connections_other": "{{count}} kết nối",
+ "platform": "Nền tảng",
+ "journalMode": "Chế độ journal",
+ "synchronous": "Đồng bộ",
+ "autoVacuum": "Tự dọn dẹp",
+ "foreignKeys": "Khóa ngoại",
+ "busyTimeout": "Thời gian chờ khóa"
+ },
+ "errorBoundary": {
+ "title": "Đã xảy ra lỗi",
+ "body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
+ "reload": "Tải lại"
+ }
}
diff --git a/client/src/i18n/locales/vi/sessions.json b/client/src/i18n/locales/vi/sessions.json
index c6632b18..5f302c84 100644
--- a/client/src/i18n/locales/vi/sessions.json
+++ b/client/src/i18n/locales/vi/sessions.json
@@ -37,7 +37,14 @@
"noEvents": "Chưa có sự kiện được ghi nhận.",
"conversation": "Hội thoại",
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
- "transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
+ "transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết.",
+ "back": "Quay lại",
+ "collapseSubagents": "Thu gọn subagent",
+ "expandSubagents": "Mở rộng subagent",
+ "dashboardRun": {
+ "title": "Một pane trên dashboard đang chạy phiên này",
+ "body": "Mở pane trong workspace để gửi tiếp, xem đầu ra hoặc dừng nó."
+ }
},
"toolBadgeTitle": "Ghi lại bởi {{tool}}",
"menu": {
@@ -45,5 +52,109 @@
"inWorkspace": "Mở trong workspace của nó",
"copyId": "Sao chép ID session",
"copyFolder": "Sao chép đường dẫn thư mục"
+ },
+ "exportCsv": "CSV",
+ "exportJson": "JSON",
+ "filterDirectory": "Lọc theo thư mục",
+ "allDirectories": "Tất cả thư mục",
+ "sortLabel": "Sắp xếp phiên",
+ "sortTimeDesc": "Theo thời gian (mới nhất)",
+ "sortTimeAsc": "Theo thời gian (cũ nhất)",
+ "sortDurationDesc": "Theo thời lượng (dài nhất)",
+ "sortDurationAsc": "Theo thời lượng (ngắn nhất)",
+ "sortPriceDesc": "Theo chi phí (cao nhất)",
+ "sortPriceAsc": "Theo chi phí (thấp nhất)",
+ "descending": "Giảm dần",
+ "ascending": "Tăng dần",
+ "filterTool": "Lọc theo công cụ AI",
+ "allTools": "Mọi công cụ AI",
+ "filterWorkspace": "Lọc theo workspace",
+ "allWorkspaces": "Mọi workspace",
+ "dateFrom": "Từ",
+ "dateTo": "Đến",
+ "clearFilters": "Xóa bộ lọc",
+ "failedList": "Không thể tải danh sách phiên",
+ "tableRegion": "Bảng phiên",
+ "openInWorkspace": "Mở trong workspace",
+ "openInWorkspaceTitle": "Một pane trên dashboard đang chạy phiên này — mở nó",
+ "remoteSourceBadgeTitle": "Được thu thập từ máy từ xa",
+ "datePicker": {
+ "placeholder": "Chọn ngày và giờ",
+ "clear": "Xóa ngày",
+ "prevMonth": "Tháng trước",
+ "nextMonth": "Tháng sau",
+ "time": "Giờ"
+ },
+ "conversation": {
+ "failedLoad": "Không thể tải transcript",
+ "transcript": "Transcript",
+ "messages_one": "{{count}} tin nhắn",
+ "messages_other": "{{count}} tin nhắn",
+ "refresh": "Làm mới hội thoại",
+ "loadingHistory": "Đang tải lịch sử…",
+ "scrollForOlder": "Cuộn lên để xem tin nhắn cũ hơn",
+ "loading": "Đang tải hội thoại…",
+ "empty": "Không tìm thấy bản ghi hội thoại.",
+ "emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.",
+ "newMessages": "Tin nhắn mới",
+ "systemReminder": "Nhắc nhở hệ thống",
+ "persistedOutput": "Đầu ra đã lưu",
+ "taskNotification": "Thông báo tác vụ",
+ "thinking": "Suy nghĩ",
+ "chars_one": "{{formatted}} ký tự",
+ "chars_other": "{{formatted}} ký tự",
+ "sender": {
+ "user": "Người dùng",
+ "assistant": "Trợ lý",
+ "orchestrator": "Agent chính",
+ "system": "Hệ thống",
+ "tool": "Công cụ"
+ },
+ "lines_one": "{{count}} dòng",
+ "lines_other": "{{count}} dòng",
+ "copyCode": "Sao chép mã",
+ "copy": "Sao chép",
+ "tool": {
+ "inputTruncated": "Đầu vào (bị cắt bớt)",
+ "command": "Lệnh",
+ "newFile": "Tệp mới",
+ "replaceAll": "thay tất cả",
+ "removed": "Đã xóa",
+ "added": "Đã thêm",
+ "pattern": "Mẫu",
+ "path": "Đường dẫn",
+ "glob": "Glob",
+ "input": "Đầu vào",
+ "empty": "trống",
+ "error": "Lỗi",
+ "output": "Đầu ra",
+ "statusError": "lỗi",
+ "statusOk": "ok",
+ "statusPending": "đang chờ"
+ }
+ },
+ "overview": {
+ "agent": "Agent",
+ "running": "đang chạy",
+ "events": "Sự kiện",
+ "perMin": "{{rate}}/phút",
+ "toolCalls": "Lần gọi công cụ",
+ "unique": "{{count}} loại",
+ "subagents": "Subagent",
+ "plusMain_one": "+{{count}} chính",
+ "plusMain_other": "+{{count}} chính",
+ "compactions": "Lần nén",
+ "errors": "Lỗi",
+ "duration": "Thời lượng",
+ "topTools": "Công cụ dùng nhiều",
+ "distinctTotal": "tổng {{count}}",
+ "noToolCalls": "Chưa có lần gọi công cụ nào.",
+ "contextCompaction": "Nén ngữ cảnh",
+ "runs_one": "{{count}} lần chạy",
+ "runs_other": "{{count}} lần chạy",
+ "noSubagents": "Phiên này không có subagent.",
+ "tokenFlow": "Luồng token",
+ "total": "tổng {{value}}",
+ "eventMix": "Cơ cấu sự kiện"
}
}
diff --git a/client/src/i18n/locales/vi/workflows.json b/client/src/i18n/locales/vi/workflows.json
index d2142c1a..caf49831 100644
--- a/client/src/i18n/locales/vi/workflows.json
+++ b/client/src/i18n/locales/vi/workflows.json
@@ -204,6 +204,15 @@
"outcomeAbandoned": "Phiên dừng mà không có Stop sạch và cũng không có lỗi rõ ràng - thường là đóng giữa chừng.",
"outcomeFallback": "Trạng thái cuối của các phiên trong phạm vi, được tính từ sự kiện hook cuối quan sát được."
}
+ },
+ "legendItems": {
+ "sessions": "Phiên",
+ "mainAgent": "Agent chính",
+ "subagentTypes": "Loại subagent",
+ "compactions": "Lần nén",
+ "completed": "Hoàn thành",
+ "error": "Lỗi",
+ "abandoned": "Bị bỏ dở"
}
},
"drillIn": {
@@ -230,7 +239,9 @@
"noEvents": "Không có sự kiện cho phiên này.",
"showingOf": "Đang hiển thị 100 / {{total}} sự kiện",
"sessionInfo": " phiên - hoạt động {{from}}%-{{to}} thời lượng phiên",
- "sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)"
+ "sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)",
+ "openSession": "Mở phiên",
+ "openInWorkspace": "Mở trong workspace"
},
"lanes": {
"title": "Tỉ lệ pass các node pipeline",
@@ -316,5 +327,7 @@
"sessions_one": "{{count}} phiên",
"sessions_other": "{{count}} phiên",
"pluginBreakdown": "{{skills}} skill được dùng · {{skillCalls}} lần gọi skill · {{mcpCalls}} lần gọi tool MCP"
- }
+ },
+ "selectedNode": "Đang chọn:",
+ "clearSelection": "Bỏ chọn"
}
diff --git a/client/src/lib/__tests__/exportData.test.ts b/client/src/lib/__tests__/exportData.test.ts
new file mode 100644
index 00000000..b9f62e2a
--- /dev/null
+++ b/client/src/lib/__tests__/exportData.test.ts
@@ -0,0 +1,25 @@
+/**
+ * @file exportData.test.ts
+ * @description Unit tests for the CSV export helper: quoting and formula neutralizing.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect } from "vitest";
+import { toCsv } from "../exportData";
+
+describe("toCsv", () => {
+ it("writes a header row and quotes cells that need it", () => {
+ const csv = toCsv(
+ [
+ { id: "a", name: 'say "hi", ok', n: 3 },
+ { id: "b", name: null, n: 0 },
+ ],
+ ["id", "name", "n"]
+ );
+ expect(csv).toBe('id,name,n\r\na,"say ""hi"", ok",3\r\nb,,0\r\n');
+ });
+
+ it("neutralizes spreadsheet formulas", () => {
+ expect(toCsv([{ v: "=HYPERLINK(1)" }], ["v"])).toBe("v\r\n'=HYPERLINK(1)\r\n");
+ });
+});
diff --git a/client/src/lib/__tests__/format.test.ts b/client/src/lib/__tests__/format.test.ts
index 536b901a..f3f1e05f 100644
--- a/client/src/lib/__tests__/format.test.ts
+++ b/client/src/lib/__tests__/format.test.ts
@@ -17,6 +17,7 @@ import {
formatTime,
getCurrentLocale,
formatModelName,
+ isoOrUndefined,
} from "../format";
describe("formatMs", () => {
@@ -256,3 +257,23 @@ describe("formatModelName", () => {
expect(formatModelName("o1-preview")).toBe("O1 Preview");
});
});
+
+describe("NaN guards", () => {
+ it("formatDuration accepts timestamps that already end in Z", () => {
+ expect(formatDuration("2026-03-05T10:00:00Z", "2026-03-05T10:00:05Z")).toBe("5s");
+ });
+ it("formatMs renders '-' for NaN", () => {
+ expect(formatMs(NaN)).toBe("-");
+ });
+ it("timeAgo renders '-' for an unparseable timestamp", () => {
+ expect(timeAgo("not a date")).toBe("-");
+ });
+});
+
+describe("isoOrUndefined", () => {
+ it("returns undefined for an empty or unparseable value instead of throwing", () => {
+ expect(isoOrUndefined("")).toBeUndefined();
+ expect(isoOrUndefined("x")).toBeUndefined();
+ expect(isoOrUndefined("2026-01-02T03:04:05Z")).toBe("2026-01-02T03:04:05.000Z");
+ });
+});
diff --git a/client/src/lib/__tests__/throttle.test.ts b/client/src/lib/__tests__/throttle.test.ts
new file mode 100644
index 00000000..0dec071c
--- /dev/null
+++ b/client/src/lib/__tests__/throttle.test.ts
@@ -0,0 +1,41 @@
+/**
+ * @file throttle.test.ts
+ * @description Unit tests for the leading+trailing throttle used by live-refresh pages.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { throttle } from "../throttle";
+
+afterEach(() => vi.useRealTimers());
+
+describe("throttle", () => {
+ it("runs at once, then at most once per window with a trailing call", () => {
+ vi.useFakeTimers();
+ const fn = vi.fn();
+ const t = throttle(fn, 1000);
+ t();
+ expect(fn).toHaveBeenCalledTimes(1);
+ // A steady stream never freezes: it fires every window, not after it stops.
+ for (let i = 0; i < 25; i++) {
+ vi.advanceTimersByTime(100);
+ t();
+ }
+ expect(fn.mock.calls.length).toBeGreaterThanOrEqual(3);
+ vi.advanceTimersByTime(1000);
+ const n = fn.mock.calls.length;
+ vi.advanceTimersByTime(5000);
+ expect(fn).toHaveBeenCalledTimes(n);
+ });
+
+ it("cancel drops a pending trailing call", () => {
+ vi.useFakeTimers();
+ const fn = vi.fn();
+ const t = throttle(fn, 1000);
+ t();
+ t();
+ t.cancel();
+ vi.advanceTimersByTime(2000);
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/client/src/lib/exportData.ts b/client/src/lib/exportData.ts
new file mode 100644
index 00000000..29602b85
--- /dev/null
+++ b/client/src/lib/exportData.ts
@@ -0,0 +1,39 @@
+/**
+ * @file exportData.ts
+ * @description Client-side "Download CSV / JSON" for list pages (Sessions,
+ * Activity, a session's events): `toCsv` turns rows into RFC 4180 CSV (and
+ * neutralizes spreadsheet formulas), `downloadText` saves a string as a file.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+/** One CSV cell: quoted when needed; a leading =, +, - or @ is prefixed with `'`
+ * so a spreadsheet never runs a value from a session as a formula. */
+function cell(value: unknown): string {
+ if (value == null) return "";
+ let s = typeof value === "object" ? JSON.stringify(value) : String(value);
+ if (typeof value === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}
+
+export function toCsv(
+ rows: readonly T[],
+ columns: readonly (keyof T & string)[]
+) {
+ const lines = [columns.join(",")];
+ for (const row of rows) lines.push(columns.map((c) => cell(row[c])).join(","));
+ return lines.join("\r\n") + "\r\n";
+}
+
+export function downloadText(filename: string, text: string, mime: string): void {
+ const url = URL.createObjectURL(new Blob([text], { type: mime }));
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+/** `name-YYYY-MM-DD.ext` */
+export function exportName(name: string, ext: "csv" | "json"): string {
+ return `${name}-${new Date().toISOString().slice(0, 10)}.${ext}`;
+}
diff --git a/client/src/lib/format.ts b/client/src/lib/format.ts
index be6f96e6..c49cbf86 100644
--- a/client/src/lib/format.ts
+++ b/client/src/lib/format.ts
@@ -163,7 +163,7 @@ import i18n from "../i18n";
* parseDate("2026-04-18T08:49:13Z") // parsed as-is
* parseDate("2026-04-18T08:49:13-04:00") // parsed as-is (explicit offset)
*/
-function parseDate(iso: string): Date {
+export function parseDate(iso: string): Date {
// Already has timezone info (Z or +/- offset) - parse directly
// (`/[+-]\d{2}:\d{2}$/` catches trailing `+04:00`-style offsets).
if (/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso)) {
@@ -297,6 +297,16 @@ export function formatDuration(start: string, end: string): string {
return formatMs(ms);
}
+/**
+ * A URL/user date value as an ISO string for an API param, or `undefined` when
+ * it is empty or unparseable (`new Date("x").toISOString()` throws).
+ */
+export function isoOrUndefined(value: string | null | undefined): string | undefined {
+ if (!value) return undefined;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
+}
+
/**
* Formats a millisecond duration as the coarsest two-unit representation:
* "Nh Nm" once >= 1 hour, "Nm Ns" once >= 1 minute, else "Ns".
@@ -311,6 +321,7 @@ export function formatDuration(start: string, end: string): string {
* formatMs(-10) // "0s"
*/
export function formatMs(ms: number): string {
+ if (Number.isNaN(ms)) return "-"; // unparseable input upstream
if (ms < 0) return "0s"; // clamp negative spans (clock skew) to zero
const totalSec = Math.floor(ms / 1000);
const hours = Math.floor(totalSec / 3600); // whole hours
@@ -334,6 +345,7 @@ export function formatMs(ms: number): string {
*/
export function timeAgo(iso: string): string {
const ms = Date.now() - parseDate(iso).getTime();
+ if (Number.isNaN(ms)) return "-";
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return i18n.t("common:time.justNow");
const minutes = Math.floor(seconds / 60);
diff --git a/client/src/lib/throttle.ts b/client/src/lib/throttle.ts
new file mode 100644
index 00000000..d3b320fb
--- /dev/null
+++ b/client/src/lib/throttle.ts
@@ -0,0 +1,34 @@
+/**
+ * @file throttle.ts
+ * @description Leading + trailing throttle for live-refresh callbacks. Unlike a
+ * trailing debounce, a steady stream of WebSocket events can never postpone the
+ * refresh forever: it fires at once, then at most once per `ms`, and once more
+ * after the burst so the last change is never lost.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+export interface Throttled {
+ (): void;
+ cancel(): void;
+}
+
+export function throttle(fn: () => void, ms: number): Throttled {
+ let last = 0;
+ let timer: ReturnType | undefined;
+ const run = () => {
+ timer = undefined;
+ last = Date.now();
+ fn();
+ };
+ const t = (() => {
+ if (timer) return;
+ const wait = last + ms - Date.now();
+ if (wait <= 0) run();
+ else timer = setTimeout(run, wait);
+ }) as Throttled;
+ t.cancel = () => {
+ clearTimeout(timer);
+ timer = undefined;
+ };
+ return t;
+}
diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts
index 2ba90fd6..32f10d5f 100644
--- a/client/src/lib/types.ts
+++ b/client/src/lib/types.ts
@@ -933,6 +933,12 @@ export interface DashboardEvent {
/** ISO timestamp the event was recorded (ingest time, not hook-reported time).
* Maps to `events.created_at`. Drives all time-bucketed charts. */
created_at: string;
+ /** Labels joined by `GET /api/events` only (absent on WebSocket pushes). */
+ session_name?: string | null;
+ session_cwd?: string | null;
+ session_lane_id?: number | null;
+ agent_name?: string | null;
+ agent_subagent_type?: string | null;
}
// ───── Overview & analytics ─────
@@ -1860,6 +1866,10 @@ export interface SessionStats {
events_by_type: Array<{ event_type: string; count: number }>;
/** Tool invocation counts within this session, most-used first. */
tools_used: Array<{ tool_name: string; count: number }>;
+ /** Exact tool-call total (`tools_used` is a top-15 list); absent on older servers. */
+ tool_call_count?: number;
+ /** Number of distinct tools used; absent on older servers. */
+ distinct_tool_count?: number;
/** Count of events representing an error (APIError, error-summary Stop) in
* this session. Drives the "N errors" badge on the detail page. */
error_count: number;
@@ -2899,31 +2909,31 @@ export const STATUS_CONFIG: Record<
// Green: the agent is actively running a tool / doing work.
working: {
labelKey: "common:status.working",
- color: "text-emerald-700 dark:text-emerald-400",
- bg: "bg-emerald-500/10 border-emerald-500/20",
- dot: "bg-emerald-400",
+ color: "text-status-success",
+ bg: "bg-status-success/10 border-status-success/25",
+ dot: "bg-status-success",
},
// Yellow: idle-between-turns, or (via the AWAITING_STATUS overlay) blocked
// on user input - attention may be required.
waiting: {
labelKey: "common:status.waiting",
- color: "text-yellow-700 dark:text-yellow-400",
- bg: "bg-yellow-500/10 border-yellow-500/20",
- dot: "bg-yellow-400",
+ color: "text-status-warning",
+ bg: "bg-status-warning/10 border-status-warning/25",
+ dot: "bg-status-warning",
},
- // Violet: the agent finished cleanly.
+ // Accent: the agent finished cleanly.
completed: {
labelKey: "common:status.completed",
- color: "text-violet-700 dark:text-violet-400",
- bg: "bg-violet-500/10 border-violet-500/20",
- dot: "bg-violet-400",
+ color: "text-accent",
+ bg: "bg-accent/10 border-accent/25",
+ dot: "bg-accent",
},
// Red: the agent ended in an error state.
error: {
labelKey: "common:status.error",
- color: "text-red-700 dark:text-red-400",
- bg: "bg-red-500/10 border-red-500/20",
- dot: "bg-red-400",
+ color: "text-status-danger",
+ bg: "bg-status-danger/10 border-status-danger/25",
+ dot: "bg-status-danger",
},
};
@@ -3107,30 +3117,30 @@ export const SESSION_STATUS_CONFIG: Record<
// Green: the session is live and running.
active: {
labelKey: "common:status.active",
- color: "text-emerald-700 dark:text-emerald-400",
- bg: "bg-emerald-500/10 border-emerald-500/20",
- dot: "bg-emerald-400",
+ color: "text-status-success",
+ bg: "bg-status-success/10 border-status-success/25",
+ dot: "bg-status-success",
},
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
waiting: {
labelKey: "common:status.waiting",
- color: "text-yellow-700 dark:text-yellow-400",
- bg: "bg-yellow-500/10 border-yellow-500/20",
- dot: "bg-yellow-400",
+ color: "text-status-warning",
+ bg: "bg-status-warning/10 border-status-warning/25",
+ dot: "bg-status-warning",
},
- // Violet: the session ended cleanly (SessionEnd).
+ // Accent: the session ended cleanly (SessionEnd).
completed: {
labelKey: "common:status.completed",
- color: "text-violet-700 dark:text-violet-400",
- bg: "bg-violet-500/10 border-violet-500/20",
- dot: "bg-violet-400",
+ color: "text-accent",
+ bg: "bg-accent/10 border-accent/25",
+ dot: "bg-accent",
},
// Red: the session ended in an error state.
error: {
labelKey: "common:status.error",
- color: "text-red-700 dark:text-red-400",
- bg: "bg-red-500/10 border-red-500/20",
- dot: "bg-red-400",
+ color: "text-status-danger",
+ bg: "bg-status-danger/10 border-status-danger/25",
+ dot: "bg-status-danger",
},
// Slate: stale session swept as "abandoned" (never cleanly ended); has no
// equivalent in STATUS_CONFIG since agents don't get abandoned.
@@ -3138,8 +3148,8 @@ export const SESSION_STATUS_CONFIG: Record<
// Muted slate distinguishes "given up / faded out" from yellow Waiting
// (attention required).
labelKey: "common:status.abandoned",
- color: "text-slate-600 dark:text-slate-400",
- bg: "bg-slate-500/10 border-slate-500/20",
- dot: "bg-slate-400",
+ color: "text-fg-secondary",
+ bg: "bg-surface-3 border-border",
+ dot: "bg-fg-muted",
},
};
diff --git a/client/src/pages/ActivityFeed.tsx b/client/src/pages/ActivityFeed.tsx
index 42d23233..a0e5d09c 100644
--- a/client/src/pages/ActivityFeed.tsx
+++ b/client/src/pages/ActivityFeed.tsx
@@ -62,12 +62,23 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef, useMemo, useSyncExternalStore } from "react";
-import { Link } from "react-router-dom";
+import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
-import { Activity, Pause, Play, RefreshCw, ChevronRight, ExternalLink } from "lucide-react";
+import {
+ Activity,
+ Pause,
+ Play,
+ RefreshCw,
+ ChevronRight,
+ ExternalLink,
+ Download,
+} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
+import { useLiveRefresh } from "../hooks/useLiveRefresh";
+import { downloadText, exportName, toCsv } from "../lib/exportData";
+import { toast } from "../lib/toast";
import { useDataScope } from "../lib/dataScope";
import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
@@ -76,9 +87,10 @@ import { useContextMenu } from "../components/ContextMenu";
import { SessionRowMenu } from "../components/SessionRowMenu";
import {
EventFilters,
- EMPTY_FILTERS,
isEmptyFilters,
expandStatusToEventTypes,
+ filtersFromParams,
+ filtersToParams,
} from "../components/EventFilters";
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
@@ -92,52 +104,65 @@ import {
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
-import { formatTime, formatDateShort, timeAgo } from "../lib/format";
+import { formatTime, formatDateShort, timeAgo, isoOrUndefined } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
const PAGE_SIZE = 50;
-// Max rows a single /api/events request can return (server cap). Refreshes
-// triggered by live events are bounded by this.
-const MAX_REFRESH = 500;
-// Debounce live-event refreshes so a burst of hook events (e.g. a stream of
-// PostToolUse results) triggers one refetch instead of dozens.
-const REFRESH_DEBOUNCE_MS = 500;
+// Throttle live-event refreshes so a burst of hook events (e.g. a stream of
+// PostToolUse results) triggers one refetch instead of dozens — and a steady
+// stream still refreshes, which a trailing debounce never did.
+const REFRESH_THROTTLE_MS = 1000;
-export function ActivityFeed() {
- const eventCtx = useContextMenu();
+export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) {
+ const eventCtx = useContextMenu();
const { t } = useTranslation("activity");
const [events, setEvents] = useState([]);
const [total, setTotal] = useState(0);
- const [page, setPage] = useState(0);
- const [filters, setFilters] = useState(EMPTY_FILTERS);
+ // Filters and page live in the URL (replace), next to History's `view`.
+ const [params, setParams] = useSearchParams();
+ const paramKey = params.toString();
+ const filters = useMemo(() => filtersFromParams(new URLSearchParams(paramKey)), [paramKey]);
+ const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0);
+ // A filter change lands on page 0 in the same update (one load, not two).
+ const setFilters = useCallback(
+ (f: EventFiltersValue) =>
+ setParams(
+ (prev) => {
+ const next = filtersToParams(f, prev);
+ next.delete("page");
+ return next;
+ },
+ { replace: true }
+ ),
+ [setParams]
+ );
+ const setPage = (fn: number | ((p: number) => number)) => {
+ const n = typeof fn === "function" ? fn(page) : fn;
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (n > 0) next.set("page", String(n));
+ else next.delete("page");
+ return next;
+ },
+ { replace: true }
+ );
+ };
const [paused, setPaused] = useState(false);
const [loading, setLoading] = useState(true);
- const [bufferCount, setBufferCount] = useState(0);
+ const [error, setError] = useState(null);
+ // Events that arrived while paused, or while reading a later page (live
+ // refresh only runs on page 1, so rows never shift under the reader).
+ const [newCount, setNewCount] = useState(0);
const [expandedEvents, setExpandedEvents] = useState>(() => new Set());
- // session_id → session name. Populated from /api/sessions on mount so rows
- // can render a friendly session pill instead of a bare UUID.
- const [sessionNameById, setSessionNameById] = useState>(() => new Map());
- // session_id → project (basename of the session's cwd). Used as the leading
- // origin segment for events whose own payload carries no cwd (e.g.
- // TurnDuration "Turn completed" rows), so they match tool-use rows.
- const [sessionProjectById, setSessionProjectById] = useState>(
- () => new Map()
- );
- // agent_id → subagent-facing info. Populated from /api/agents on mount so the
- // subagent pill can show subagent_type (e.g. "frontend-reviewer") instead of
- // a raw ID. Main agents intentionally yield no pill.
+ // agent_id → subagent-facing info for the agents of the sessions on screen,
+ // fetched in one call per new set of sessions (never every agent).
const [agentInfoById, setAgentInfoById] = useState>(() => new Map());
+ const knownAgentSessions = useRef(new Set());
- const bufferRef = useRef([]);
const pausedRef = useRef(paused);
- // Refs let the websocket handler read the latest filter/page without
- // re-subscribing on every change.
- const apiParamsRef = useRef>({});
- const pageRef = useRef(0);
- const refreshTimerRef = useRef | null>(null);
-
pausedRef.current = paused;
- pageRef.current = page;
+ const live = page === 0 && !paused;
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
@@ -159,172 +184,155 @@ export function ActivityFeed() {
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
session_id: filters.session_id.length > 0 ? filters.session_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),
};
}, [filters]);
- apiParamsRef.current = apiParams;
-
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
- const load = useCallback(async () => {
- setLoading(true);
- try {
- const { events: data, total: totalCount } = await api.events.list({
- ...apiParams,
- limit: PAGE_SIZE,
- offset: page * PAGE_SIZE,
- });
- setEvents(data);
- setTotal(totalCount);
- } catch (err) {
- // Non-fatal: keep the previous list; log so dev tools surface the
- // failure instead of raising an unhandled promise rejection.
- console.error("Failed to load events:", err);
- } finally {
- setLoading(false);
- }
- }, [apiParams, page, scope]);
+ // Newest request wins: a filter typed during a slow load never gets
+ // overwritten by the older response.
+ const loadSeq = useRef(0);
+ const load = useCallback(
+ async (quiet = false) => {
+ const seq = ++loadSeq.current;
+ if (!quiet) setLoading(true);
+ try {
+ const { events: data, total: totalCount } = await api.events.list({
+ ...apiParams,
+ limit: PAGE_SIZE,
+ offset: page * PAGE_SIZE,
+ });
+ if (seq !== loadSeq.current) return;
+ setEvents(data);
+ setTotal(totalCount);
+ setError(null);
+ if (page === 0) setNewCount(0);
+ } catch (err) {
+ if (seq === loadSeq.current) setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ if (seq === loadSeq.current) setLoading(false);
+ }
+ },
+ [apiParams, page, scope]
+ );
useEffect(() => {
load();
}, [load]);
- // Reset to page 0 whenever filters change so the user lands on the first
- // page of the new filtered result set.
+ // Agent labels for the sessions on screen that we have not seen yet.
useEffect(() => {
- setPage(0);
- }, [apiParams]);
-
- useEffect(() => {
- let cancelled = false;
- // Mount-time name lookups: ask for the server's safety cap so even
- // long-running deployments get full pill coverage. These are one-shot
- // fetches; cost computation is bounded by returned rows but agents
- // don't have it and sessions only compute it on the page-sized list,
- // so this is cheap.
- api.sessions
- .list({ limit: 10000 })
- .then(({ sessions }) => {
- if (cancelled) return;
- const map = new Map();
- const projectMap = new Map();
- for (const s of sessions) {
- // Always populate so the EventDetail panel's "Session" row shows
- // an identifiable label even for unnamed sessions. Matches the
- // fallback used by SessionDetail's header.
- const label = s.name?.trim() || `Session ${s.id.slice(0, 8)}`;
- map.set(s.id, label);
- const project = projectFromCwd(s.cwd);
- if (project) projectMap.set(s.id, project);
- }
- setSessionNameById(map);
- setSessionProjectById(projectMap);
- })
- .catch(() => {
- // Non-fatal: rows just render without the session name pill.
- });
+ const missing = [...new Set(events.map((e) => e.session_id))].filter(
+ (sid) => !knownAgentSessions.current.has(sid)
+ );
+ if (missing.length === 0) return;
+ for (const sid of missing) knownAgentSessions.current.add(sid);
api.agents
- .list({ limit: 10000 })
- .then(({ agents }) => {
- if (cancelled) return;
- const map = new Map();
- for (const a of agents) {
- map.set(a.id, {
- type: a.type,
- subagent_type: a.subagent_type,
- name: a.name,
- parent_agent_id: a.parent_agent_id,
- });
- }
- setAgentInfoById(map);
- })
+ .list({ session_ids: missing, limit: 5000 })
+ .then(({ agents }) =>
+ setAgentInfoById((prev) => {
+ const map = new Map(prev);
+ for (const a of agents)
+ map.set(a.id, {
+ type: a.type,
+ subagent_type: a.subagent_type,
+ name: a.name,
+ parent_agent_id: a.parent_agent_id,
+ });
+ return map;
+ })
+ )
.catch(() => {
// Non-fatal: subagent pills fall back to the short-id label.
+ for (const sid of missing) knownAgentSessions.current.delete(sid);
});
- return () => {
- cancelled = true;
- };
- }, []);
+ }, [events]);
- // Refetches the current page using the latest filter state. Capped at
- // MAX_REFRESH (server limit); at the default PAGE_SIZE this is a no-op cap.
- const refreshWithPagination = useCallback(async () => {
- const size = Math.min(PAGE_SIZE, MAX_REFRESH);
- try {
- const { events: data, total: totalCount } = await api.events.list({
- ...apiParamsRef.current,
- limit: size,
- offset: pageRef.current * PAGE_SIZE,
- });
- setEvents(data);
- setTotal(totalCount);
- } catch (err) {
- // Non-fatal: swallow the error so a flaky websocket burst doesn't
- // spam unhandled rejections; the next live event / manual refresh
- // will try again.
- console.error("Failed to refresh events:", err);
- }
- }, []);
+ // Session labels come joined on each row (`session_name`, `session_cwd`).
+ const sessionNameById = useMemo(() => {
+ const map = new Map();
+ for (const e of events)
+ map.set(e.session_id, e.session_name?.trim() || `Session ${e.session_id.slice(0, 8)}`);
+ return map;
+ }, [events]);
+ // Live: page 1 refreshes (throttled); elsewhere, and while paused, new
+ // events only bump a counter.
+ useLiveRefresh(() => void load(true), {
+ enabled: live,
+ throttleMs: REFRESH_THROTTLE_MS,
+ match: (msg) => msg.type === "new_event" || isRemoteDataRefreshMessage(msg),
+ });
useEffect(() => {
- const unsubscribe = eventBus.subscribe((msg) => {
- // A remote source finished syncing → new remote events may exist; do a
- // filter-aware refresh (respects pause via refreshWithPagination's guard).
- if (isRemoteDataRefreshMessage(msg)) {
- if (!pausedRef.current) refreshWithPagination();
- return;
- }
- if (msg.type !== "new_event") return;
- const event = msg.data as DashboardEvent;
- if (pausedRef.current) {
- bufferRef.current = [event, ...bufferRef.current];
- setBufferCount(bufferRef.current.length);
- return;
- }
- // Debounce bursts into a single filter-aware refresh.
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
- refreshTimerRef.current = setTimeout(() => {
- refreshTimerRef.current = null;
- refreshWithPagination();
- }, REFRESH_DEBOUNCE_MS);
+ if (live) return;
+ return eventBus.subscribe((msg) => {
+ if (msg.type === "new_event") setNewCount((n) => n + 1);
});
- return () => {
- unsubscribe();
- if (refreshTimerRef.current) {
- clearTimeout(refreshTimerRef.current);
- refreshTimerRef.current = null;
- }
- };
- }, [refreshWithPagination]);
+ }, [live]);
function resume() {
- pausedRef.current = false;
- bufferRef.current = [];
- setBufferCount(0);
setPaused(false);
+ setNewCount(0);
+ if (page !== 0) setPage(0);
// Catch-up via filtered refresh so buffered non-matching events don't leak in.
- refreshWithPagination();
+ else void load(true);
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Precompute the project name per event so row rendering doesn't re-parse
- // event.data JSON on every render pass.
+ // event.data JSON on every render pass; events without their own cwd fall
+ // back to their session's.
const projectByEventId = useMemo(() => {
const map = new Map();
- for (const e of events) map.set(e.id, projectFromEvent(e));
+ for (const e of events) map.set(e.id, projectFromEvent(e) ?? projectFromCwd(e.session_cwd));
return map;
}, [events]);
+ async function handleExport(kind: "csv" | "json") {
+ try {
+ const all: DashboardEvent[] = [];
+ for (let offset = 0; offset < 10000; offset += 500) {
+ const res = await api.events.list({ ...apiParams, limit: 500, offset });
+ all.push(...res.events);
+ if (res.events.length < 500 || all.length >= res.total) break;
+ }
+ const name = exportName("activity", kind);
+ if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json");
+ else
+ downloadText(
+ name,
+ toCsv(all, [
+ "id",
+ "created_at",
+ "session_id",
+ "session_name",
+ "event_type",
+ "tool_name",
+ "agent_id",
+ "agent_name",
+ "summary",
+ ]),
+ "text/csv"
+ );
+ } catch (err) {
+ toast.error(err);
+ }
+ }
+
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
return (
{eventCtx.menu && (
-
+ {!embedded && (
+
+ )}
-
{t("title")}
+ {/* Under History's tab bar the tab already names the page. */}
+
+ {t("title")}
+
{wsConnected ? (
@@ -353,9 +366,7 @@ export function ActivityFeed() {
{t("subtitle")}
{paused && (
-
- {t("paused", { count: bufferCount })}
-
+ {t("paused", { count: newCount })}
)}
@@ -372,7 +383,18 @@ export function ActivityFeed() {
>
)}
-
+ handleExport("csv")} className="btn-ghost" disabled={total === 0}>
+ {t("exportCsv")}
+
+ handleExport("json")} className="btn-ghost" disabled={total === 0}>
+ {t("exportJson")}
+
+ load()}
+ className="btn-ghost"
+ aria-label={t("common:refresh")}
+ title={t("common:refresh")}
+ >
@@ -393,6 +415,29 @@ export function ActivityFeed() {
/>
+ {error && (
+
+
+ {t("failedLoad")}: {error}
+
+ load()} className="btn-ghost text-xs">
+ {t("common:retry")}
+
+
+ )}
+
+ {!paused && page > 0 && newCount > 0 && (
+ setPage(0)}
+ className="mb-3 text-xs px-3 py-1 rounded-full bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25"
+ >
+ {t("newEvents", { count: newCount })}
+
+ )}
+
{!loading && events.length === 0 ? (
+ {/* The row is a plain container; its expand control is a real
+ button and "Session" a sibling link (no nested interactives). */}
{
+ onClick={(e) => {
+ if ((e.target as HTMLElement).closest("a,button")) return;
if (event.id != null) toggleEvent(event.id);
}}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- if (event.id != null) toggleEvent(event.id);
- }
- }}
- aria-expanded={isOpen}
- onContextMenu={(e) => eventCtx.open(e, event.session_id)}
+ onContextMenu={(e) => eventCtx.open(e, event)}
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
>
-
+
event.id != null && toggleEvent(event.id)}
+ aria-expanded={isOpen}
+ aria-label={
+ isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
+ }
+ className="-m-1 p-1 rounded flex-shrink-0 -mr-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
+ >
+
+
@@ -455,10 +504,7 @@ export function ActivityFeed() {
{(() => {
const sname = sessionNameById.get(event.session_id);
- const project =
- projectByEventId.get(event.id) ??
- sessionProjectById.get(event.session_id) ??
- null;
+ const project = projectByEventId.get(event.id) ?? null;
const origin = buildOriginLabel(
project,
sname ?? null,
@@ -521,7 +567,7 @@ export function ActivityFeed() {
onClick={() => setPage(0)}
disabled={page === 0}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
- aria-label="First page"
+ aria-label={t("firstPage")}
>
«
@@ -584,7 +630,7 @@ export function ActivityFeed() {
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
- aria-label="Last page"
+ aria-label={t("lastPage")}
>
»
diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx
index d8126e37..34033b55 100644
--- a/client/src/pages/Dashboard.tsx
+++ b/client/src/pages/Dashboard.tsx
@@ -61,10 +61,11 @@ import {
useSyncExternalStore,
useMemo,
useRef,
+ lazy,
+ Suspense,
type ReactNode,
} from "react";
-import { useNavigate, useSearchParams } from "react-router-dom";
-import { Workflows } from "./Workflows";
+import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
LayoutDashboard,
@@ -89,20 +90,25 @@ import {
Search,
Download,
Clock,
+ AlertCircle,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
+import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { StatCard } from "../components/StatCard";
import { AgentCard } from "../components/AgentCard";
import { useContextMenu } from "../components/ContextMenu";
import { SessionRowMenu } from "../components/SessionRowMenu";
+import { NeedsYouStrip } from "../components/NeedsYouStrip";
+import { RouteErrorBoundary } from "../components/RouteErrorBoundary";
import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { Tip } from "../components/Tip";
-import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton";
+import { Skeleton } from "../components/Skeleton";
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
+import { statusFromEventType } from "../lib/event-grouping";
import type {
Stats,
Agent,
@@ -379,7 +385,7 @@ function Heatmap({ weeks }: { weeks: Array
;
color?: string;
@@ -418,7 +424,7 @@ function AnalyticsSparkline({
function CostTrendLine({
data,
- color = "#10b981",
+ color = "rgb(var(--status-success))",
}: {
data: Array<{ date: string; cost: number }>;
color?: string;
@@ -648,45 +654,6 @@ function DonutChart({
);
}
-function StatPill({
- label,
- value,
- raw,
- sub,
- icon: Icon,
- color = "text-accent",
- loading = false,
-}: {
- label: string;
- value: string | number;
- raw?: string;
- sub?: string;
- icon: React.ElementType;
- color?: string;
- loading?: boolean;
-}) {
- return (
-
-
- {label}
-
-
- {loading ? (
-
- ) : (
-
- {raw ? {value} : value}
-
- )}
- {loading ? (
-
- ) : (
- sub &&
{sub}
- )}
-
- );
-}
-
/** A single chart-card placeholder: title bar, chart body, two footer lines.
* Uses the shared pulsing so the whole grid shimmers while data
* loads (mirrors the chart-card layout so nothing jumps when data arrives). */
@@ -698,7 +665,7 @@ function ChartCardSkeleton({
bodyH?: string;
}) {
return (
-
+
@@ -714,7 +681,7 @@ function ChartCardSkeleton({
* still being fetched. */
function AnalyticsChartsSkeleton() {
return (
-
+
@@ -736,49 +703,64 @@ function AnalyticsTab() {
const [lastUpdate, setLastUpdate] = useState(new Date());
const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
- const [params] = useSearchParams();
- const [analyticsSubTab, setAnalyticsSubTab] = useState<
- "tokens" | "cost" | "workflow" | "productivity"
- >(() => {
- const sub = params.get("sub");
- return sub === "tokens" || sub === "workflow" || sub === "productivity" ? sub : "cost";
- });
+ // The URL is the state: `?sub=` is read on every render and written back.
+ const [params, setParams] = useSearchParams();
+ const subParam = params.get("sub");
+ const analyticsSubTab: "tokens" | "cost" | "workflow" | "productivity" =
+ subParam === "tokens" || subParam === "workflow" || subParam === "productivity"
+ ? subParam
+ : "cost";
+ const setAnalyticsSubTab = (sub: typeof analyticsSubTab) =>
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("tab", "analytics");
+ if (sub === "cost") next.delete("sub");
+ else next.set("sub", sub);
+ return next;
+ },
+ { replace: true }
+ );
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
+ const [loadError, setLoadError] = useState(null);
+ // Responses can land out of order (a slow poll after a fast refresh); only
+ // the newest request may write state.
+ const loadSeq = useRef(0);
const load = useCallback(async () => {
+ const seq = ++loadSeq.current;
try {
const [result, cost] = await Promise.all([
api.analytics.get(),
api.pricing.totalCost().catch(() => null),
]);
+ if (seq !== loadSeq.current) return;
setData(result);
setCostData(cost);
+ setLoadError(null);
setLastUpdate(new Date());
+ } catch (err) {
+ if (seq === loadSeq.current)
+ setLoadError(err instanceof Error ? err.message : t("dashboard:failedLoad"));
} finally {
- setLoading(false);
+ if (seq === loadSeq.current) setLoading(false);
}
}, [scope]);
useEffect(() => {
load();
- const interval = setInterval(load, 15000);
- return () => clearInterval(interval);
}, [load]);
- useEffect(() => {
- return eventBus.subscribe((msg) => {
- if (
- msg.type === "session_created" ||
- msg.type === "session_updated" ||
- msg.type === "new_event" ||
- msg.type === "agent_created" ||
- isRemoteDataRefreshMessage(msg)
- ) {
- load();
- }
- });
- }, [load]);
+ useLiveRefresh(load, {
+ intervalMs: 15000,
+ match: (msg) =>
+ msg.type === "session_created" ||
+ msg.type === "session_updated" ||
+ msg.type === "new_event" ||
+ msg.type === "agent_created" ||
+ isRemoteDataRefreshMessage(msg),
+ });
function handleExport() {
if (!data) return;
@@ -848,6 +830,14 @@ function AnalyticsTab() {
dailySessionsLocal.push({ date, count });
}
dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date));
+ // Calendar days, zero-filled: "last 30/7 days" means days, not active days.
+ const sessionsLast30 = Array.from({ length: 30 }, (_, i) => {
+ const d = new Date(today);
+ d.setDate(today.getDate() - (29 - i));
+ const dateStr = localDateStr(d);
+ return { date: dateStr, count: sessMap[dateStr] ?? 0 };
+ });
+ const maxSessionsDay = Math.max(1, ...sessionsLast30.map((d) => d.count));
// Convert daily_costs - server already returns local dates
const dailyCostsLocal: Array<{ date: string; cost: number }> = [];
@@ -876,13 +866,15 @@ function AnalyticsTab() {
.sort((a, b) => b.cost - a.cost);
const locale = i18n.resolvedLanguage ?? i18n.language;
const weekdayOrder = [1, 2, 3, 4, 5, 6, 0];
+ // 2026-01-04 is a Sunday; a LOCAL date, so the label never shifts a day
+ // west of UTC. 30 days hold some weekdays 5 times and others 4, so the bar
+ // is the average per occurrence, not the sum.
const weekdayCosts = weekdayOrder.map((dow) => {
const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
- new Date(Date.UTC(2026, 0, 4 + dow))
+ new Date(2026, 0, 4 + dow, 12)
);
- const cost = dailyCostLast30
- .filter((day) => new Date(day.date + "T12:00:00").getDay() === dow)
- .reduce((sum, day) => sum + day.cost, 0);
+ const days = dailyCostLast30.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow);
+ const cost = days.length ? days.reduce((sum, day) => sum + day.cost, 0) / days.length : 0;
return { label, cost };
});
const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1);
@@ -893,17 +885,25 @@ function AnalyticsTab() {
(data?.tokens.total_cache_read ?? 0) +
(data?.tokens.total_cache_write ?? 0);
const tokenMixSegments = [
- { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "#60a5fa" },
- { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "#34d399" },
+ {
+ label: t("common:token.input"),
+ value: data?.tokens.total_input ?? 0,
+ color: "rgb(var(--syn-builtin))",
+ },
+ {
+ label: t("common:token.output"),
+ value: data?.tokens.total_output ?? 0,
+ color: "rgb(var(--status-success))",
+ },
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
- color: "#a78bfa",
+ color: "rgb(var(--syn-keyword))",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
- color: "#facc15",
+ color: "rgb(var(--status-warning))",
},
].filter((s) => s.value > 0);
@@ -911,29 +911,37 @@ function AnalyticsTab() {
const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1;
const maxEventTypeCount = data?.event_types[0]?.count ?? 1;
+ // Hit rate = cache reads over every prompt token (fresh input + cache read +
+ // cache write); output tokens are never cacheable.
+ const promptTokens =
+ (data?.tokens.total_input ?? 0) +
+ (data?.tokens.total_cache_read ?? 0) +
+ (data?.tokens.total_cache_write ?? 0);
const cacheHitPct =
- totalTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / totalTokens) * 100) : 0;
+ promptTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / promptTokens) * 100) : 0;
+ // The headline excludes cache reads, which dwarf everything else and cost a tenth.
+ const nonCacheReadTokens = totalTokens - (data?.tokens.total_cache_read ?? 0);
const sessionOutcomeSegments = [
{
label: t("common:status.completed"),
value: data?.sessions_by_status?.completed ?? 0,
- color: "#8b5cf6",
+ color: "rgb(var(--syn-keyword))",
},
{
label: t("common:status.active"),
value: data?.sessions_by_status?.active ?? 0,
- color: "#10b981",
+ color: "rgb(var(--status-success))",
},
{
label: t("common:status.error"),
value: data?.sessions_by_status?.error ?? 0,
- color: "#ef4444",
+ color: "rgb(var(--status-danger))",
},
{
label: t("common:status.abandoned"),
value: data?.sessions_by_status?.abandoned ?? 0,
- color: "#f59e0b",
+ color: "rgb(var(--status-warning))",
},
].filter((s) => s.value > 0);
@@ -941,31 +949,31 @@ function AnalyticsTab() {
{
label: t("common:status.completed"),
value: data?.agents_by_status?.completed ?? 0,
- color: "#8b5cf6",
+ color: "rgb(var(--syn-keyword))",
},
{
label: t("common:status.working"),
value: data?.agents_by_status?.working ?? 0,
- color: "#10b981",
+ color: "rgb(var(--status-success))",
},
{
label: t("common:status.waiting"),
value: data?.agents_by_status?.waiting ?? 0,
- color: "#eab308",
+ color: "rgb(var(--status-warning))",
},
{
label: t("common:status.error"),
value: data?.agents_by_status?.error ?? 0,
- color: "#ef4444",
+ color: "rgb(var(--status-danger))",
},
].filter((s) => s.value > 0);
const EVENT_TYPE_COLORS: Record = {
PreToolUse: "bg-status-success",
- PostToolUse: "bg-blue-500",
- Stop: "bg-violet-400",
- SubagentStop: "bg-yellow-400",
- Notification: "bg-orange-400",
+ PostToolUse: "bg-syntax-builtin",
+ Stop: "bg-syntax-keyword",
+ SubagentStop: "bg-status-warning",
+ Notification: "bg-syntax-number",
};
return (
@@ -988,55 +996,77 @@ function AnalyticsTab() {
+ {loadError && (
+
+
+
{loadError}
+
+ {t("common:retry")}
+
+
+ )}
+
{/* Key stats */}
-
-
-
-
-
@@ -1049,7 +1079,12 @@ function AnalyticsTab() {
{t("eventActivity")}
-
+
@@ -1100,6 +1135,7 @@ function AnalyticsTab() {
setAnalyticsSubTab(key)}
+ aria-pressed={analyticsSubTab === key}
className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
analyticsSubTab === key
? "bg-surface-4 text-fg-secondary"
@@ -1123,7 +1159,7 @@ function AnalyticsTab() {
{
label: t("common:token.input"),
value: data?.tokens.total_input ?? 0,
- color: "bg-blue-500",
+ color: "bg-syntax-builtin",
},
{
label: t("common:token.output"),
@@ -1133,12 +1169,12 @@ function AnalyticsTab() {
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
- color: "bg-violet-400",
+ color: "bg-syntax-keyword",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
- color: "bg-yellow-400",
+ color: "bg-status-warning",
},
].map(({ label, value, color }) => (
{t("cacheEfficiency")}
-
- {cacheHitPct}%
-
+ {cacheHitPct}%
@@ -1176,7 +1210,7 @@ function AnalyticsTab() {
{
label: t("common:token.input"),
value: data?.tokens.total_input ?? 0,
- color: "text-blue-500",
+ color: "text-syntax-builtin",
},
{
label: t("common:token.output"),
@@ -1186,12 +1220,12 @@ function AnalyticsTab() {
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
- color: "text-violet-700 dark:text-violet-400",
+ color: "text-syntax-keyword",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
- color: "text-yellow-700 dark:text-yellow-400",
+ color: "text-status-warning",
},
{ label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
].map(({ label, value, color }) => (
@@ -1282,9 +1316,14 @@ function AnalyticsTab() {
label: formatModelName(b.model) ?? b.model,
value: Math.round(b.cost * 100),
color:
- ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][
- i % 6
- ] ?? "#6b7280",
+ [
+ "rgb(var(--syn-keyword))",
+ "rgb(var(--syn-builtin))",
+ "rgb(var(--status-success))",
+ "rgb(var(--status-warning))",
+ "rgb(var(--status-danger))",
+ "rgb(var(--syn-operator))",
+ ][i % 6] ?? "rgb(var(--fg-muted))",
}))}
formatTotal={(cents) => fmtCost(cents / 100)}
/>
@@ -1323,7 +1362,7 @@ function AnalyticsTab() {
{t("noDailyCostData")}
) : (
<>
-
{t("last30Days")}
+
{t("avgPerWeekday30d")}
{weekdayCosts.map(({ label, cost }) => (
))}
{t("common:total")}
-
+
{fmtCost(totalCost30d)}
@@ -1365,7 +1404,7 @@ function AnalyticsTab() {
label={subagent_type}
count={count}
max={maxAgentTypeCount}
- color="bg-violet-400"
+ color="bg-syntax-keyword"
/>
))}
@@ -1430,7 +1469,11 @@ function AnalyticsTab() {
{/* The former Workflows page, folded into this sub-tab. */}
-
+
+ }>
+
+
+
)}
@@ -1449,7 +1492,7 @@ function AnalyticsTab() {
label={tool_name}
count={count}
max={maxToolCount}
- color="bg-yellow-400"
+ color="bg-status-warning"
/>
))}
@@ -1500,18 +1543,13 @@ function AnalyticsTab() {
{t("noSessionTrendData")}
) : (
<>
-
+
- {dailySessionsLocal
+ {sessionsLast30
.slice(-7)
.reverse()
.map(({ date, count }) => {
- const maxD = Math.max(
- ...(dailySessionsLocal.length > 0
- ? dailySessionsLocal
- : [{ count: 1 }]
- ).map((d) => d.count)
- );
+ const maxD = maxSessionsDay;
return (
@@ -1546,24 +1584,27 @@ function AnalyticsTab() {
}
function SystemHealthTab() {
+ const { t } = useTranslation("dashboard");
const [info, setInfo] = useState(null);
const [workflow, setWorkflow] = useState(null);
+ const [loadError, setLoadError] = useState(null);
const loadData = useCallback(async () => {
try {
const [infoRes, workflowRes] = await Promise.all([api.settings.info(), api.workflows.get()]);
setInfo(infoRes as any);
setWorkflow(workflowRes);
+ setLoadError(null);
} catch (e) {
- console.error(e);
+ setLoadError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
loadData();
- const int = setInterval(loadData, 30000); // 30s is sufficient for health metrics
- return () => clearInterval(int);
}, [loadData]);
+ // 30s is sufficient for health metrics; skipped while the tab is hidden.
+ useLiveRefresh(loadData, { intervalMs: 30000 });
const stats = useMemo(() => {
if (!info || !workflow) return null;
@@ -1576,7 +1617,7 @@ function SystemHealthTab() {
const agentPct = totalEntries > 0 ? ((info.db.counts?.agents || 0) / totalEntries) * 100 : 0;
const eventPct = totalEntries > 0 ? ((info.db.counts?.events || 0) / totalEntries) * 100 : 0;
- const modelStats = (workflow.modelDelegation?.tokensByModel || [])
+ const modelStats = [...(workflow.modelDelegation?.tokensByModel || [])]
.sort((a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens))
.slice(0, 6);
const totalTokens = modelStats.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0);
@@ -1600,6 +1641,20 @@ function SystemHealthTab() {
};
}, [info, workflow]);
+ if (loadError && !info) {
+ return (
+
+
+
+ {t("failedLoad")}: {loadError}
+
+
+ {t("common:retry")}
+
+
+ );
+ }
+
if (!info || !workflow || !stats) {
return (
@@ -1667,22 +1722,28 @@ function SystemHealthTab() {
- Runtime
+
+ {t("health.runtime")}
+
- {info.server.cpus} cores · {info.server.arch}
+ {t("health.cores", { count: info.server.cpus })} · {info.server.arch}
- Uptime
+
+ {t("health.uptime")}
+
{formatUptime(info.server.uptime)}
-
CPU (1/5/15m)
+
+ {t("health.cpu")}
+
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
-
Node RSS
+
+ {t("health.nodeRss")}
+
{formatBytes(info.server.memory.rss)}
@@ -1705,11 +1768,15 @@ function SystemHealthTab() {
- Host Memory
+ {t("health.hostMemory")}
{memUsedPct.toFixed(0)}%
@@ -1722,16 +1789,22 @@ function SystemHealthTab() {
- V8 Heap
+ {t("health.heap")}
{heapUsedPct.toFixed(0)}%
85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-blue-600"}`}
+ className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-syntax-builtin"}`}
style={{ width: `${heapUsedPct}%` }}
/>
@@ -1744,11 +1817,17 @@ function SystemHealthTab() {
-
- Storage
+
+
+ {t("health.storage")}
+
⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/
@@ -1758,7 +1837,9 @@ function SystemHealthTab() {
-
Database
+
+ {t("health.database")}
+
{formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"}
@@ -1767,7 +1848,15 @@ function SystemHealthTab() {
{/* Pie chart + legend using same pattern as Analytics DonutChart */}
{
@@ -1825,7 +1914,7 @@ function SystemHealthTab() {
fontWeight="700"
fontFamily="monospace"
>
- {totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries}
+ {fmt(totalEntries)}
- total
+ {t("health.total")}
{[
{
- label: "Sessions",
+ label: t("health.sessions"),
value: info.db.counts?.sessions ?? 0,
- color: "#60a5fa",
+ color: "rgb(var(--syn-builtin))",
pct: sessPct,
},
{
- label: "Agents",
+ label: t("health.agents"),
value: info.db.counts?.agents ?? 0,
- color: "#8b5cf6",
+ color: "rgb(var(--syn-keyword))",
pct: agentPct,
},
{
- label: "Events",
+ label: t("health.events"),
value: info.db.counts?.events ?? 0,
- color: "#34d399",
+ color: "rgb(var(--status-success))",
pct: eventPct,
},
].map((item) => (
@@ -1886,13 +1975,21 @@ function SystemHealthTab() {
- Health Score
+
+ {t("health.score")}
+
- ⓘ Formula
+ ⓘ {t("health.formula")}
@@ -1900,7 +1997,13 @@ function SystemHealthTab() {
{/* Ring gauge - centered */}
= 90 ? "#34d399" : healthScore >= 70 ? "#fbbf24" : "#f87171"}
+ stroke={
+ healthScore >= 90
+ ? "rgb(var(--status-success))"
+ : healthScore >= 70
+ ? "rgb(var(--status-warning))"
+ : "rgb(var(--status-danger))"
+ }
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`}
@@ -1954,21 +2063,22 @@ function SystemHealthTab() {
-
Cache
-
+
{t("health.cache")}
+
{cacheHitRate.toFixed(0)}%
-
15% = critical`}
- >
+
-
Errors
+
{t("health.errors")}
@@ -1978,23 +2088,25 @@ function SystemHealthTab() {
-
Compact
-
+
{t("health.compact")}
+
{workflow.compaction?.totalCompactions ?? 0}
-
Saved
+
{t("health.saved")}
- {((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K
+ {fmt(workflow.compaction?.tokensRecovered ?? 0)}
@@ -2008,11 +2120,13 @@ function SystemHealthTab() {
-
- Token Usage
+
+
+ {t("health.tokenUsage")}
+
- {(totalTokens / 1000).toFixed(1)}K total
+ {t("health.totalSuffix", { value: fmt(totalTokens) })}
@@ -2021,18 +2135,24 @@ function SystemHealthTab() {
const pct =
totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0;
const colors = [
- "bg-blue-500",
- "bg-violet-400",
+ "bg-syntax-builtin",
+ "bg-syntax-keyword",
"bg-status-success",
"bg-status-warning",
- "bg-pink-400",
- "bg-cyan-400",
+ "bg-syntax-operator",
+ "bg-syntax-property",
];
return (
No model data
+ {t("health.noModelData")}
)}
@@ -2064,16 +2184,27 @@ function SystemHealthTab() {
-
- Concurrency
+
+
+ {t("health.concurrency")}
+
-
{lanes.length} intervals
+
+ {t("health.intervals", { count: lanes.length })}
+
{/* Sparkline-style bar chart matching Analytics sparkline */}
l.count > 0).length}\nAvg: ${lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}`}
+ raw={t("health.concurrencyTip", {
+ peak: maxLaneCount,
+ active: lanes.filter((l) => l.count > 0).length,
+ avg:
+ lanes.length > 0
+ ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
+ : "0",
+ })}
>
{lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => {
@@ -2081,11 +2212,11 @@ function SystemHealthTab() {
maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4;
const color =
lane.count > 5
- ? "#f87171"
+ ? "rgb(var(--status-danger))"
: lane.count > 2
- ? "#fbbf24"
+ ? "rgb(var(--status-warning))"
: lane.count > 0
- ? "#34d399"
+ ? "rgb(var(--status-success))"
: "rgb(var(--border))";
return (
- No concurrency data
+ {t("health.noConcurrencyData")}
)}
-
+
-
Peak
+
{t("health.peak")}
{maxLaneCount}
l.count > 0).length} of ${lanes.length} intervals have active sessions.`}
+ raw={t("health.activeTip", {
+ active: lanes.filter((l) => l.count > 0).length,
+ total: lanes.length,
+ })}
>
-
Active
+
{t("health.active")}
{lanes.filter((l) => l.count > 0).length}
-
+
-
Avg
-
+
{t("health.avg")}
+
{lanes.length > 0
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
: "0"}
@@ -2146,9 +2280,13 @@ function SystemHealthTab() {
- Tool Usage
+
+ {t("health.toolUsage")}
+
-
top {topTools.length}
+
+ {t("health.top", { count: topTools.length })}
+
@@ -2156,19 +2294,23 @@ function SystemHealthTab() {
const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0;
const colors = [
"bg-status-warning",
- "bg-blue-500",
+ "bg-syntax-builtin",
"bg-status-success",
- "bg-violet-400",
- "bg-pink-400",
- "bg-cyan-400",
+ "bg-syntax-keyword",
+ "bg-syntax-operator",
+ "bg-syntax-property",
"bg-status-danger",
- "bg-indigo-400",
+ "bg-accent",
];
return (
- {tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count}
+ {fmt(tool.count)}
);
})}
{topTools.length === 0 && (
-
No tool data yet
+
{t("health.noToolData")}
)}
@@ -2200,9 +2342,9 @@ function SystemHealthTab() {
-
+
- Subagent Effectiveness
+ {t("health.subagentEffectiveness")}
@@ -2219,11 +2361,17 @@ function SystemHealthTab() {
- {item.subagent_type || "default"}
+ {item.subagent_type || t("health.defaultType")}
No subagent data yet
+
{t("health.noSubagentData")}
)}
@@ -2254,12 +2402,14 @@ function SystemHealthTab() {
-
Integration
+
+ {t("health.integration")}
+
- {info.hooks.installed ? "Active" : "Offline"}
+ {info.hooks.installed ? t("health.hooksActive") : t("common:offline")}
@@ -2269,7 +2419,10 @@ function SystemHealthTab() {
-
No project hooks registered
+
{t("health.noHooks")}
)}
-
+
-
WebSocket Active
+
+ {t("health.wsActive")}
+
- {info.server.ws_connections} connection
- {info.server.ws_connections !== 1 ? "s" : ""}
+ {t("health.connections", { count: info.server.ws_connections })}
@@ -2310,8 +2461,10 @@ function SystemHealthTab() {
-
- Platform
+
+
+ {t("health.platform")}
+
{info.server.node_version}
@@ -2319,11 +2472,11 @@ function SystemHealthTab() {
{[
{
- label: "Journal Mode",
+ label: t("health.journalMode"),
value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL",
},
{
- label: "Synchronous",
+ label: t("health.synchronous"),
value:
info.db.pragmas?.synchronous === 2
? "FULL"
@@ -2331,10 +2484,22 @@ function SystemHealthTab() {
? "NORMAL"
: "OFF",
},
- { label: "Auto-Vacuum", value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF" },
- { label: "Foreign Keys", value: info.db.pragmas?.foreign_keys ? "ON" : "OFF" },
- { label: "Busy Timeout", value: `${info.db.pragmas?.busy_timeout || 5000}ms` },
- { label: "Platform", value: `${info.server.platform} / ${info.server.arch}` },
+ {
+ label: t("health.autoVacuum"),
+ value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF",
+ },
+ {
+ label: t("health.foreignKeys"),
+ value: info.db.pragmas?.foreign_keys ? "ON" : "OFF",
+ },
+ {
+ label: t("health.busyTimeout"),
+ value: `${info.db.pragmas?.busy_timeout || 5000}ms`,
+ },
+ {
+ label: t("health.platform"),
+ value: `${info.server.platform} / ${info.server.arch}`,
+ },
].map((row) => (
{row.label}
@@ -2357,50 +2522,32 @@ function SystemHealthTab() {
);
}
-export function Dashboard() {
- const navigate = useNavigate();
+// The Workflow sub-tab (d3 charts) loads on first open, not with the Dashboard.
+const Workflows = lazy(() => import("./Workflows").then((m) => ({ default: m.Workflows })));
+
+/** Rows kept in the Monitor's recent-activity list, on load and on a push alike. */
+const RECENT_LIMIT = 30;
+
+/**
+ * The Monitor's recent-activity column. Its own component (own state, own
+ * fetch and push subscription) so a new event re-renders this list only, not
+ * the whole Dashboard and its agent tree.
+ */
+function RecentActivityPanel({ sessionsById }: { sessionsById: Map
}) {
const { t } = useTranslation("dashboard");
const eventCtx = useContextMenu();
-
- // Persistent Tab State
- const [tabParams] = useSearchParams();
- const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => {
- const tab = tabParams.get("tab");
- if (tab === "monitor" || tab === "analytics" || tab === "health") return tab;
- return (
- (localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "analytics"
- );
- });
-
- useEffect(() => {
- localStorage.setItem("dashboard_tab", activeTab);
- }, [activeTab]);
-
- const [stats, setStats] = useState(null);
- const [activeAgents, setActiveAgents] = useState([]);
const [recentEvents, setRecentEvents] = useState([]);
- const [totalCost, setTotalCost] = useState(null);
- const [allSubagents, setAllSubagents] = useState([]);
- const [sessionsById, setSessionsById] = useState>(new Map());
- const [expandedAgents, setExpandedAgents] = useState>(new Set());
- const [error, setError] = useState(null);
+ const [scope] = useDataScope();
- // Dynamic item counts based on available container height
- const agentsContainerRef = useRef(null);
+ // Dynamic row count based on the column's available height.
const activityContainerRef = useRef(null);
- const [visibleAgentCount, setVisibleAgentCount] = useState(5);
const [visibleActivityCount, setVisibleActivityCount] = useState(8);
useEffect(() => {
- const AGENT_ROW_H = 56; // ~px per agent card row
const ACTIVITY_ROW_H = 44; // ~px per activity row
const HEADER_H = 40; // section header height
function recalc() {
- if (agentsContainerRef.current) {
- const h = agentsContainerRef.current.clientHeight;
- setVisibleAgentCount(Math.max(3, Math.floor((h - HEADER_H) / AGENT_ROW_H)));
- }
if (activityContainerRef.current) {
const h = activityContainerRef.current.clientHeight;
setVisibleActivityCount(Math.max(3, Math.floor((h - HEADER_H) / ACTIVITY_ROW_H)));
@@ -2408,56 +2555,205 @@ export function Dashboard() {
}
const ro = new ResizeObserver(recalc);
- if (agentsContainerRef.current) ro.observe(agentsContainerRef.current);
if (activityContainerRef.current) ro.observe(activityContainerRef.current);
recalc();
return () => ro.disconnect();
- }, [activeTab]);
+ }, []);
+
+ const load = useCallback(() => {
+ api.events
+ .list({ limit: RECENT_LIMIT })
+ .then((r) => setRecentEvents(r.events))
+ .catch(() => undefined); // a failed refresh keeps the last list
+ }, [scope]);
+ useEffect(() => {
+ load();
+ }, [load]);
+ useLiveRefresh(load, { intervalMs: 10000 });
+
+ // A new event is prepended at once (no reload); the poll reconciles.
+ useEffect(
+ () =>
+ eventBus.subscribe((msg: WSMessage) => {
+ if (msg.type !== "new_event") return;
+ const newEvent = msg.data as DashboardEvent;
+ setRecentEvents((prev) => {
+ // Deduplicate by event ID to prevent WS + polling race condition
+ if (newEvent.id && prev.some((e) => e.id === newEvent.id)) return prev;
+ return [newEvent, ...prev].slice(0, RECENT_LIMIT);
+ });
+ }),
+ []
+ );
+
+ return (
+
+
+
{t("recentActivity")}
+
+ {t("viewAll")}
+
+
+ {recentEvents.length === 0 ? (
+
+ ) : (
+
+ {eventCtx.menu && (
+
+ )}
+ {recentEvents.slice(0, visibleActivityCount).map((event, i) => (
+
eventCtx.open(e, event.session_id)}
+ >
+
+
+ {event.summary || event.event_type}
+
+ {(() => {
+ // Session label: real name when one exists, else the
+ // short ID - keeps every activity row attributable.
+ const sname =
+ (event.session_name ?? sessionsById.get(event.session_id)?.name)?.trim() || "";
+ const isAuto = /^Session [0-9a-f]{8}$/i.test(sname);
+ return (
+
+ {sname && !isAuto ? (
+ sname
+ ) : (
+ {event.session_id.slice(0, 8)}
+ )}
+
+ );
+ })()}
+ {event.tool_name && (
+
{event.tool_name}
+ )}
+
+ {timeAgo(event.created_at)}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+type DashboardTab = "monitor" | "analytics" | "health";
+function isDashboardTab(v: string | null): v is DashboardTab {
+ return v === "monitor" || v === "analytics" || v === "health";
+}
+
+export function Dashboard() {
+ const { t } = useTranslation("dashboard");
+
+ // Tab state: `?tab=` wins, else the last tab used, else Analytics. Written
+ // back to the URL (replace) so a reload or a shared link lands on it.
+ const [tabParams, setTabParams] = useSearchParams();
+ const urlTab = tabParams.get("tab");
+ const storedTab = localStorage.getItem("dashboard_tab");
+ const activeTab: DashboardTab = isDashboardTab(urlTab)
+ ? urlTab
+ : isDashboardTab(storedTab)
+ ? storedTab
+ : "analytics";
+ const setActiveTab = (tab: DashboardTab) => {
+ localStorage.setItem("dashboard_tab", tab);
+ setTabParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("tab", tab);
+ if (tab !== "analytics") next.delete("sub");
+ return next;
+ },
+ { replace: true }
+ );
+ };
+
+ const [stats, setStats] = useState(null);
+ const [activeAgents, setActiveAgents] = useState([]);
+ const [totalCost, setTotalCost] = useState(null);
+ const [allSubagents, setAllSubagents] = useState([]);
+ const [sessionsById, setSessionsById] = useState>(new Map());
+ const [expandedAgents, setExpandedAgents] = useState>(new Set());
+ // Calls that failed on the last load; each panel keeps its last good data.
+ const [error, setError] = useState(null);
+ // The agents column scrolls, so no main agent is ever dropped.
+ const agentsContainerRef = useRef(null);
// Global data scope; a change re-runs `load` (the api layer injects the
// matching `sources` param into the calls below).
const [scope] = useDataScope();
+ const monitorSeq = useRef(0);
const load = useCallback(async () => {
- try {
- const [statsRes, workingRes, waitingRes, eventsRes, costRes, sessionsRes] = await Promise.all(
- [
- api.stats.get(),
- api.agents.list({ status: "working", limit: 20 }),
- api.agents.list({ status: "waiting", limit: 20 }),
- api.events.list({ limit: 30 }),
- api.pricing.totalCost(),
- api.sessions.list({ status: "active", limit: 100 }),
- ]
- );
- setStats(statsRes);
+ const seq = ++monitorSeq.current;
+ const failed: string[] = [];
+ // One failing endpoint must not blank the others: each call keeps its own
+ // catch and the panel it feeds keeps its last good value.
+ const soft = (p: Promise): Promise =>
+ p.catch((err: unknown) => {
+ failed.push(err instanceof Error ? err.message : String(err));
+ return null;
+ });
+ const [statsRes, workingRes, waitingRes, costRes, sessionsRes] = await Promise.all([
+ soft(api.stats.get()),
+ soft(api.agents.list({ status: "working", limit: 20 })),
+ soft(api.agents.list({ status: "waiting", limit: 20 })),
+ soft(api.pricing.totalCost()),
+ soft(api.sessions.list({ status: "active", limit: 100 })),
+ ]);
+ if (seq !== monitorSeq.current) return;
+ if (statsRes) setStats(statsRes);
+ if (costRes) setTotalCost(costRes.total_cost);
+ if (sessionsRes) setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s])));
+ if (workingRes && waitingRes) {
const active = [...workingRes.agents, ...waitingRes.agents];
setActiveAgents(active);
- setRecentEvents(eventsRes.events);
- setTotalCost(costRes.total_cost);
- setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s])));
- setError(null);
-
- // Fetch all subagents for each active main agent's session
+ // Every subagent of the active main agents' sessions, in ONE call.
const activeSessionIds = [
...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)),
];
- const subagentResults = await Promise.all(
- activeSessionIds.map((sid) => api.agents.list({ session_id: sid, limit: 100 }))
- );
- const subs = subagentResults.flatMap((r) => r.agents).filter((a) => a.type === "subagent");
- setAllSubagents(subs);
- } catch (err) {
- setError(err instanceof Error ? err.message : t("failedLoad"));
+ const subsRes = activeSessionIds.length
+ ? await soft(api.agents.list({ session_ids: activeSessionIds, limit: 2000 }))
+ : { agents: [] };
+ if (seq !== monitorSeq.current) return;
+ if (subsRes) setAllSubagents(subsRes.agents.filter((a) => a.type === "subagent"));
}
- }, [t, scope]);
+ setError(failed[0] ?? null);
+ }, [scope]);
+ const monitorOn = activeTab === "monitor";
useEffect(() => {
- load();
- const interval = setInterval(load, 10000);
- return () => clearInterval(interval);
- }, [load]);
+ if (monitorOn) load();
+ }, [load, monitorOn]);
+
+ useLiveRefresh(load, {
+ enabled: monitorOn,
+ intervalMs: 10000,
+ match: (msg) =>
+ msg.type === "agent_created" ||
+ msg.type === "agent_updated" ||
+ msg.type === "session_created" ||
+ msg.type === "session_updated" ||
+ isRemoteDataRefreshMessage(msg),
+ });
// Auto-expand agents with active subagents (walk up the full parent chain)
useEffect(() => {
@@ -2487,31 +2783,6 @@ export function Dashboard() {
});
}, [allSubagents]);
- useEffect(() => {
- const debounceRef = { timer: null as ReturnType | null };
- return eventBus.subscribe((msg: WSMessage) => {
- if (
- msg.type === "agent_created" ||
- msg.type === "agent_updated" ||
- msg.type === "session_created" ||
- msg.type === "session_updated" ||
- isRemoteDataRefreshMessage(msg)
- ) {
- // Debounce rapid-fire updates (e.g., 5 agents created in 100ms)
- if (debounceRef.timer) clearTimeout(debounceRef.timer);
- debounceRef.timer = setTimeout(load, 300);
- }
- if (msg.type === "new_event") {
- setRecentEvents((prev) => {
- const newEvent = msg.data as DashboardEvent;
- // Deduplicate by event ID to prevent WS + polling race condition
- if (newEvent.id && prev.some((e) => e.id === newEvent.id)) return prev;
- return [newEvent, ...prev.slice(0, 14)];
- });
- }
- });
- }, [load]);
-
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
// Memoize agent tree structure to avoid recalculating on every render
@@ -2552,17 +2823,11 @@ export function Dashboard() {
return { childrenByParent, getDescendants };
}, [allSubagents]);
- if (error) {
- return (
-
-
{t("failedConnect")}
-
{error}
-
- {t("common:retry")}
-
-
- );
- }
+ const tabs: Array<{ key: DashboardTab; label: string; icon: typeof BarChart3 }> = [
+ { key: "analytics", label: t("tabs.analytics"), icon: BarChart3 },
+ { key: "monitor", label: t("tabs.monitor"), icon: Activity },
+ { key: "health", label: t("tabs.health"), icon: Server },
+ ];
return (
@@ -2591,50 +2856,59 @@ export function Dashboard() {
{/* Tabs */}
-
-
setActiveTab("analytics")}
- className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
- activeTab === "analytics"
- ? "bg-accent/15 text-accent shadow-sm"
- : "text-fg-muted hover:text-fg-secondary"
- }`}
- >
- Analytics
-
-
setActiveTab("monitor")}
- className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
- activeTab === "monitor"
- ? "bg-accent/15 text-accent shadow-sm"
- : "text-fg-muted hover:text-fg-secondary"
- }`}
- >
- Monitor
-
-
setActiveTab("health")}
- className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
- activeTab === "health"
- ? "bg-accent/15 text-accent shadow-sm"
- : "text-fg-muted hover:text-fg-secondary"
- }`}
- >
- Health
-
+
+ {tabs.map(({ key, label, icon: Icon }) => (
+ setActiveTab(key)}
+ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
+ activeTab === key
+ ? "bg-accent/15 text-accent shadow-sm"
+ : "text-fg-muted hover:text-fg-secondary"
+ }`}
+ >
+ {label}
+
+ ))}
-
- {t("common:refresh")}
-
+ {/* Analytics and Health carry their own Refresh; one per view. */}
+ {monitorOn && (
+
+ {t("common:refresh")}
+
+ )}
+ {activeTab !== "health" && }
+
{activeTab === "monitor" ? (
+ {error && (
+
+
+
+ {t("failedLoad")}: {error}
+
+
+ {t("common:retry")}
+
+
+ )}
{/* Stats grid - 2 rows of 3 avoids the 6-column squeeze */}
a.status === "working").length : ""}
icon={GitBranch}
- accentColor="text-violet-700 dark:text-violet-400"
+ accentColor="text-syntax-keyword"
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
loading={!stats}
/>
{isExpanded ? (
@@ -2753,7 +3031,7 @@ export function Dashboard() {
empty rather than as a misalignment. */}
{!hasChildren && !isSubagent && (
@@ -2761,7 +3039,7 @@ export function Dashboard() {
)}
{isSubagent && (
-
+
)}
{hasChildren && isExpanded && (
-
+
{children.map((child) =>
renderAgentNode(child, depth + 1, childAncestors)
)}
@@ -2790,7 +3068,7 @@ export function Dashboard() {
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 })}
{activeDesc > 0 && (
@@ -2811,9 +3089,8 @@ export function Dashboard() {
// was `a.type === "subagent"` with no parentage check,
// which surfaced every nested subagent twice: once
// indented under its main, and once flush at root level.
- const visibleMains = activeAgents
- .filter((a) => a.type === "main")
- .slice(0, visibleAgentCount);
+ // Every main agent: the panel scrolls, nothing is dropped.
+ const visibleMains = activeAgents.filter((a) => a.type === "main");
const renderedInTree = new Set
();
for (const m of visibleMains) {
const stack: string[] = [m.id];
@@ -2853,84 +3130,7 @@ export function Dashboard() {
{/* Recent activity */}
-
-
-
{t("recentActivity")}
-
navigate("/activity")} className="btn-ghost text-xs">
- {t("viewAll")}
-
-
- {recentEvents.length === 0 ? (
-
- ) : (
-
- {eventCtx.menu && (
-
- )}
- {recentEvents.slice(0, visibleActivityCount).map((event, i) => (
-
navigate(`/sessions/${event.session_id}`)}
- onContextMenu={(e) => eventCtx.open(e, event.session_id)}
- >
-
-
- {event.summary || event.event_type}
-
- {(() => {
- // Session label: real name when one exists, else the
- // short ID - keeps every activity row attributable.
- const sname = sessionsById.get(event.session_id)?.name?.trim() || "";
- const isAuto = /^Session [0-9a-f]{8}$/i.test(sname);
- return (
-
- {sname && !isAuto ? (
- sname
- ) : (
- {event.session_id.slice(0, 8)}
- )}
-
- );
- })()}
- {event.tool_name && (
-
- {event.tool_name}
-
- )}
-
- {timeAgo(event.created_at)}
-
-
- ))}
-
- )}
-
+
) : activeTab === "analytics" ? (
diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx
index 010a73ea..ce262fe1 100644
--- a/client/src/pages/History.tsx
+++ b/client/src/pages/History.tsx
@@ -17,7 +17,7 @@ interface HistoryView {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
- Panel: ComponentType;
+ Panel: ComponentType<{ embedded?: boolean }>;
}
/**
@@ -42,8 +42,10 @@ export function History() {
activeId={active.id}
onSelect={(id) => setParams(id === defaultView.id ? {} : { view: id }, { replace: true })}
tabs={views.map(({ id, label, Icon }) => ({ id, label, Icon }))}
- />
-
+ >
+ {/* `embedded`: the tab bar names the view, so the panel drops its own title. */}
+
+
);
}
diff --git a/client/src/pages/SessionDetail.tsx b/client/src/pages/SessionDetail.tsx
index 1733326b..2bbcbd25 100644
--- a/client/src/pages/SessionDetail.tsx
+++ b/client/src/pages/SessionDetail.tsx
@@ -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([]);
const [eventsTotal, setEventsTotal] = useState(0);
const [eventsLoadingMore, setEventsLoadingMore] = useState(false);
- const [filters, setFilters] = useState(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(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([]);
+ const isDashboardRun = runs.some((h) => h.sessionId === id && h.status === "running");
const [error, setError] = useState(null);
const [expandedAgents, setExpandedAgents] = useState>(() => {
return new Set();
});
- const [activeTab, setActiveTab] = useState("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>(() => new Set(["agents"]));
@@ -184,8 +218,6 @@ export function SessionDetail() {
// Refs let the websocket handler access latest state without re-subscribing.
const eventApiParamsRef = useRef | null>(null);
const eventsLoadedCountRef = useRef(0);
- const eventsRefreshTimerRef = useRef | null>(null);
- const loadTimerRef = useRef | 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() {
{/* Header */}
-
+
-
+
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
-
+
)}
+ {(() => {
+ const href = workspaceHref(session, runs);
+ return href ? (
+
+
{t("openInWorkspace")}
+
+ ) : null;
+ })()}
-
+
@@ -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]"
}`}
>
{t("detail.waitingBanner.title")}
{cfg && (
-
+
{" · "}
{t(cfg.labelKey)}
)}
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
@@ -711,12 +742,12 @@ export function SessionDetail() {
{session.awaiting_input_since && (
@@ -727,9 +758,9 @@ export function SessionDetail() {
);
})()}
- {isDashboardRun && (
+ {isDashboardRun && workspaceHref(session, runs) && (
@@ -737,13 +768,10 @@ export function SessionDetail() {
- {t("detail.dashboardRun.title", "This session is being driven from the Run page")}
+ {t("detail.dashboardRun.title")}
- {t(
- "detail.dashboardRun.body",
- "Send follow-ups, watch streaming output, or stop the run from there."
- )}
+ {t("detail.dashboardRun.body")}
@@ -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() {
setTranscriptNotFound(false)}
className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
+ aria-label={t("common:dismiss")}
>
-
+
)}
@@ -817,7 +846,7 @@ export function SessionDetail() {
{workflows.length > 0 && (
-
+
{wfT("runs.sessionTitle")}
· {workflows.length}
@@ -830,7 +859,7 @@ export function SessionDetail() {
) : (
<>
-
+
{t("detail.agents")}
· {agents.length}
@@ -896,7 +925,11 @@ export function SessionDetail() {
{isExpanded ? (
@@ -908,7 +941,7 @@ export function SessionDetail() {
)}
{isSubagent && !hasChildren && }
{isSubagent && (
-
+
)}
+
{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 })}
@@ -1074,8 +1107,24 @@ export function SessionDetail() {
{visitedTabs.has("timeline") && (
-
-
+
+
+
+
+
exportEvents("csv")}
+ className="btn-ghost text-xs"
+ disabled={eventsTotal === 0}
+ >
+ {t("exportCsv")}
+
+
exportEvents("json")}
+ className="btn-ghost text-xs"
+ disabled={eventsTotal === 0}
+ >
+ {t("exportJson")}
+
+ // Off-screen rows skip layout/paint (a long timeline is not virtualized).
+
event.id != null && toggleEvent(event.id)}
diff --git a/client/src/pages/Sessions.tsx b/client/src/pages/Sessions.tsx
index 6360511b..e4a8ab22 100644
--- a/client/src/pages/Sessions.tsx
+++ b/client/src/pages/Sessions.tsx
@@ -52,8 +52,8 @@
*
* ----------------------------------------------------------------------------- */
-import { useEffect, useState, useCallback, useSyncExternalStore } from "react";
-import { Link, useNavigate } from "react-router-dom";
+import { useEffect, useMemo, useRef, useState, useCallback, useSyncExternalStore } from "react";
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
FolderOpen,
@@ -62,12 +62,18 @@ import {
RefreshCw,
SortDesc,
SortAsc,
- ChevronDown,
Play,
Server,
+ Download,
+ AlertCircle,
} from "lucide-react";
import { api } from "../lib/api";
-import type { RemoteSource } from "../lib/api";
+import type { RemoteSource, RunHandle } from "../lib/api";
+import { useLiveRefresh } from "../hooks/useLiveRefresh";
+import { useAiTools } from "../hooks/useAiTools";
+import { useLanes } from "../hooks/useLanes";
+import { toast } from "../lib/toast";
+import { downloadText, exportName, toCsv } from "../lib/exportData";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
@@ -76,35 +82,60 @@ import { SessionToolBadge } from "../components/SessionToolBadge";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { useContextMenu } from "../components/ContextMenu";
-import { SessionRowMenu } from "../components/SessionRowMenu";
-import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
-import {
- effectiveSessionStatus,
- isSessionAwaitingInput,
- sessionAwaitingReason,
-} from "../lib/types";
+import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
+import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
+import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types";
const PAGE_SIZE = 10;
-export function Sessions() {
+export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const rowCtx = useContextMenu();
const { t } = useTranslation("sessions");
+ const { tools } = useAiTools();
+ const { lanes } = useLanes();
+ // Filters, sort and page live in the URL (replace, no history spam), so a
+ // reload, the back button and a shared link all land on the same view.
+ const [params, setParams] = useSearchParams();
+ const filter = params.get("status") ?? "";
+ const search = params.get("q") ?? "";
+ const cwd = params.get("cwd") ?? "";
+ const tool = params.get("tool") ?? "";
+ const lane = params.get("lane") ?? "";
+ const from = params.get("from") ?? "";
+ const to = params.get("to") ?? "";
+ const sortBy = params.get("sort") ?? "time";
+ const sortDesc = params.get("asc") !== "1";
+ const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0);
+ /** Writes params; any change but the page itself goes back to page 0 in the
+ * SAME update, so one filter change is one load. */
+ const update = useCallback(
+ (patch: Record) =>
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ for (const [k, v] of Object.entries(patch)) {
+ if (v) next.set(k, v);
+ else next.delete(k);
+ }
+ if (!("page" in patch)) next.delete("page");
+ return next;
+ },
+ { replace: true }
+ ),
+ [setParams]
+ );
+ const setPage = (fn: (p: number) => number) => update({ page: String(fn(page) || "") });
+
const [sessions, setSessions] = useState([]);
const [total, setTotal] = useState(0);
- const [filter, setFilter] = useState("");
- // `searchInput` is what the user types; `search` is the debounced value
- // actually sent to the server. Without debouncing, every keystroke would
- // hit /api/sessions.
- const [searchInput, setSearchInput] = useState("");
- const [search, setSearch] = useState("");
+ // `searchInput` is what the user types; `q` in the URL is the debounced value
+ // actually sent to the server.
+ const [searchInput, setSearchInput] = useState(search);
const [loading, setLoading] = useState(true);
- const [page, setPage] = useState(0);
-
- const [cwd, setCwd] = useState("");
- const [sortBy, setSortBy] = useState("time");
- const [sortDesc, setSortDesc] = useState(true);
+ const [error, setError] = useState(null);
+ const [exporting, setExporting] = useState(false);
const [directories, setDirectories] = useState([]);
// Global data scope (which source machines to show). Included in `load`'s deps
// so switching scope re-fetches; the actual `sources` param is injected by the
@@ -112,9 +143,8 @@ export function Sessions() {
const [scope] = useDataScope();
// source id → label, so remote-origin rows show a friendly badge.
const [sourceLabels, setSourceLabels] = useState>(() => new Map());
- // Set of session IDs that are currently being driven by an in-flight Run
- // handle on /run. Lets us badge those rows with a "Run" link.
- const [dashboardRunIds, setDashboardRunIds] = useState>(new Set());
+ // Live dashboard runs, so a row a pane holds links straight to that pane.
+ const [runs, setRuns] = useState([]);
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: t("filterAll"), value: "" },
@@ -128,9 +158,17 @@ export function Sessions() {
// Debounce the search input → 300 ms after the user stops typing, the
// committed value flips and triggers a fresh fetch.
useEffect(() => {
- const id = window.setTimeout(() => setSearch(searchInput.trim()), 300);
+ const id = window.setTimeout(() => {
+ if (searchInput.trim() !== search) update({ q: searchInput.trim() || null });
+ }, 300);
return () => window.clearTimeout(id);
- }, [searchInput]);
+ }, [searchInput, search, update]);
+
+ // `?q=` changed from outside (a link to plain /sessions, back/forward): the
+ // box follows, so the debounce above never writes the old text back.
+ useEffect(() => {
+ setSearchInput((cur) => (cur.trim() === search ? cur : search));
+ }, [search]);
useEffect(() => {
api.sessions
@@ -156,115 +194,131 @@ export function Sessions() {
loadSourceLabels();
}, [loadSourceLabels]);
- // Server-side pagination: only the visible page is fetched. Cost
- // computation on the server scales with PAGE_SIZE, not with the total
- // session count, so this stays cheap regardless of how many sessions
- // exist in the database.
+ /** The list query for the current filters (paging added by the caller). */
+ const query = useMemo(
+ () => ({
+ // "Waiting" is an overlay on active sessions (awaiting_input_since);
+ // the server filters it before the limit so paging stays exact.
+ status: filter === "waiting" ? "active" : filter || undefined,
+ awaiting: filter === "waiting" || undefined,
+ q: search || undefined,
+ cwd: cwd || undefined,
+ tool: tool || undefined,
+ lane: lane ? Number(lane) : undefined,
+ // A date input is a local calendar day: `to` covers that whole day.
+ from: isoOrUndefined(from && `${from}T00:00:00`),
+ to: isoOrUndefined(to && `${to}T23:59:59.999`),
+ sort_by: sortBy,
+ sort_desc: sortDesc,
+ }),
+ [filter, search, cwd, tool, lane, from, to, sortBy, sortDesc]
+ );
+
+ // Server-side pagination: only the visible page is fetched. A newer request
+ // (filter typed while a load is in flight) wins; older responses are dropped.
+ const loadSeq = useRef(0);
const load = useCallback(async () => {
+ const seq = ++loadSeq.current;
try {
- // The "waiting" filter is a UI-only overlay derived from the
- // awaiting_input_since column - the underlying SessionStatus is
- // still "active". Map it to a client-side filter on top of the
- // active set so paging/totals stay consistent with the visible rows.
- if (filter === "waiting") {
- const res = await api.sessions.list({
- status: "active",
- q: search || undefined,
- cwd: cwd || undefined,
- sort_by: sortBy,
- sort_desc: sortDesc,
- limit: 10000,
- offset: 0,
- });
- const waiting = res.sessions.filter(isSessionAwaitingInput);
- setTotal(waiting.length);
- setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE));
- return;
- }
- const params: {
- status?: string;
- q?: string;
- cwd?: string;
- sort_by?: string;
- sort_desc?: boolean;
- limit: number;
- offset: number;
- } = {
+ const res = await api.sessions.list({
+ ...query,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
- sort_by: sortBy,
- sort_desc: sortDesc,
- };
- if (filter) params.status = filter;
- if (search) params.q = search;
- if (cwd) params.cwd = cwd;
- const res = await api.sessions.list(params);
+ });
+ if (seq !== loadSeq.current) return;
setSessions(res.sessions);
setTotal(res.total);
+ setError(null);
+ } catch (err) {
+ if (seq === loadSeq.current) setError(err instanceof Error ? err.message : t("failedList"));
} finally {
- setLoading(false);
+ if (seq === loadSeq.current) setLoading(false);
}
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
- }, [filter, search, cwd, sortBy, sortDesc, page, scope]);
+ }, [query, page, scope, t]);
useEffect(() => {
load();
}, [load]);
- // Reset to page 0 whenever filters or sort changes.
- useEffect(() => {
- setPage(0);
- }, [filter, search, cwd, sortBy, sortDesc]);
-
- useEffect(() => {
- return eventBus.subscribe((msg) => {
- if (msg.type === "session_created" || msg.type === "session_updated") {
- load();
- }
- if (msg.type === "new_event") {
- const ev = msg.data as DashboardEvent;
- if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") {
- load();
- }
- }
- if (msg.type === "run_status") {
- loadDashboardRuns();
- }
- // A remote source finished syncing: new remote sessions may have landed.
- if (msg.type === "remote_source.status") {
- loadSourceLabels();
- if (isRemoteDataRefreshMessage(msg)) load();
- } else if (isRemoteDataRefreshMessage(msg)) {
- load();
- }
- });
- // loadDashboardRuns is a stable useCallback declared below; referenced at
- // event time only (not in deps) to avoid a temporal-dead-zone at render.
- }, [load, loadSourceLabels]);
-
- // Pull active Run handles so we can mark which sessions are being driven
- // from /run right now. Refresh on mount, on run_status WS messages, and
- // every 15s as a safety net for stale browser state.
+ // Pull live Run handles so a row a dashboard pane holds can open that pane.
const loadDashboardRuns = useCallback(() => {
api.run
.list()
- .then((r) => {
- const ids = new Set();
- for (const h of r.items) {
- if (h.sessionId) ids.add(h.sessionId);
- }
- setDashboardRunIds(ids);
- })
+ .then((r) => setRuns(r.items))
.catch(() => undefined);
}, []);
useEffect(() => {
loadDashboardRuns();
- const t = setInterval(loadDashboardRuns, 15000);
- return () => clearInterval(t);
}, [loadDashboardRuns]);
+ useLiveRefresh(loadDashboardRuns, {
+ intervalMs: 15000,
+ // A pane starting/stopping re-broadcasts its lane (`lane_update`); the
+ // server sends no run_status any more. The 15 s poll covers extra slots.
+ match: (msg) => msg.type === "lane_update",
+ });
+ useLiveRefresh(load, {
+ match: (msg) => {
+ if (msg.type === "session_created" || msg.type === "session_updated") return true;
+ if (msg.type === "new_event") {
+ const ev = msg.data as DashboardEvent;
+ return ev.event_type === "Stop" || ev.event_type === "SessionEnd";
+ }
+ // A remote source finished syncing: new remote sessions may have landed.
+ return isRemoteDataRefreshMessage(msg);
+ },
+ });
+ useEffect(
+ () =>
+ eventBus.subscribe((msg) => {
+ if (msg.type === "remote_source.status") loadSourceLabels();
+ }),
+ [loadSourceLabels]
+ );
+
+ async function handleExport(kind: "csv" | "json") {
+ setExporting(true);
+ try {
+ const res = await api.sessions.list({ ...query, limit: 10000 });
+ const name = exportName("sessions", kind);
+ if (kind === "json")
+ downloadText(name, JSON.stringify(res.sessions, null, 2), "application/json");
+ else
+ downloadText(
+ name,
+ toCsv(res.sessions, [
+ "id",
+ "name",
+ "status",
+ "tool",
+ "cwd",
+ "model",
+ "started_at",
+ "ended_at",
+ "agent_count",
+ "cost",
+ "lane_id",
+ ]),
+ "text/csv"
+ );
+ } catch (err) {
+ toast.error(err);
+ } finally {
+ setExporting(false);
+ }
+ }
+
+ const hasFilters = !!(search || filter || cwd || tool || lane || from || to);
+
+ // A `?page=` past the end (a stale link, rows deleted meanwhile) goes back to
+ // the first page rather than showing an empty table.
+ const pageCount = Math.ceil(total / PAGE_SIZE);
+ useEffect(() => {
+ if (!loading && pageCount > 0 && page >= pageCount) update({ page: null });
+ }, [loading, page, pageCount, update]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// The server already paginates, so the rendered page IS the loaded list.
const paged = sessions;
@@ -284,12 +338,17 @@ export function Sessions() {
)}
-
-
-
+ {!embedded && (
+
+
+
+ )}
-
{t("title")}
+ {/* Under History's tab bar the tab already names the page. */}
+
+ {t("title")}
+
{wsConnected ? (
@@ -304,13 +363,31 @@ export function Sessions() {
{t("sessionCount", { count: total })}
- {filter ? ` ${filter}` : ""}
+ {filter
+ ? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}`
+ : ""}
-
- {t("common:refresh")}
-
+
+ handleExport("csv")}
+ className="btn-ghost"
+ disabled={exporting || total === 0}
+ >
+ {t("exportCsv")}
+
+ handleExport("json")}
+ className="btn-ghost"
+ disabled={exporting || total === 0}
+ >
+ {t("exportJson")}
+
+
+ {t("common:refresh")}
+
+
{/* Filters */}
@@ -321,6 +398,7 @@ export function Sessions() {
setSearchInput(e.target.value)}
className="input w-full pl-10"
@@ -331,17 +409,17 @@ export function Sessions() {
setCwd(e.target.value)}
- className="input w-full text-ellipsis bg-surface-1 pr-9 appearance-none cursor-pointer"
+ onChange={(e) => update({ cwd: e.target.value || null })}
+ aria-label={t("filterDirectory")}
+ className="select w-full text-ellipsis"
>
- All Directories
+ {t("allDirectories")}
{directories.map((d) => (
{truncate(d, 30)}
))}
-
{/* Sort Controls */}
@@ -349,22 +427,23 @@ export function Sessions() {
setSortBy(e.target.value)}
- className="bg-transparent w-full text-sm text-fg-secondary outline-none pl-3 pr-8 appearance-none cursor-pointer whitespace-nowrap"
+ onChange={(e) => update({ sort: e.target.value === "time" ? null : e.target.value })}
+ aria-label={t("sortLabel")}
+ className="select w-full border-0 bg-transparent py-0 whitespace-nowrap"
>
- Sort by Time ({sortDesc ? "Newest" : "Oldest"})
+ {sortDesc ? t("sortTimeDesc") : t("sortTimeAsc")}
- Sort by Duration ({sortDesc ? "Longest" : "Shortest"})
+ {sortDesc ? t("sortDurationDesc") : t("sortDurationAsc")}
- Sort by Price ({sortDesc ? "Highest" : "Lowest"})
+ {sortDesc ? t("sortPriceDesc") : t("sortPriceAsc")}
-
setSortDesc(!sortDesc)}
+ onClick={() => update({ asc: sortDesc ? "1" : null })}
className="p-1.5 rounded hover:bg-surface-3 text-fg-secondary hover:text-fg-primary transition-colors shrink-0"
- title={sortDesc ? "Descending" : "Ascending"}
+ title={sortDesc ? t("descending") : t("ascending")}
+ aria-label={sortDesc ? t("descending") : t("ascending")}
>
{sortDesc ? : }
@@ -375,7 +454,8 @@ export function Sessions() {
{FILTER_OPTIONS.map((opt) => (
setFilter(opt.value)}
+ onClick={() => update({ status: opt.value || null })}
+ aria-pressed={filter === opt.value}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
filter === opt.value
? "bg-surface-4 text-fg-secondary"
@@ -388,15 +468,106 @@ export function Sessions() {
- {!loading && filtered.length === 0 ? (
+ {/* Tool / workspace / date range */}
+
+ update({ tool: e.target.value || null })}
+ aria-label={t("filterTool")}
+ className="select py-1 text-xs w-auto"
+ >
+ {t("allTools")}
+ {tools
+ .filter((x) => x.installed || x.id === tool)
+ .map((x) => (
+
+ {x.label}
+
+ ))}
+
+ update({ lane: e.target.value || null })}
+ aria-label={t("filterWorkspace")}
+ className="select py-1 text-xs w-auto max-w-[16rem]"
+ >
+ {t("allWorkspaces")}
+ {lanes.map((l) => (
+
+ {l.title || l.cwd}
+
+ ))}
+
+
+ {t("dateFrom")}
+ update({ from: e.target.value || null })}
+ className="input py-1 text-xs w-auto"
+ />
+
+
+ {t("dateTo")}
+ update({ to: e.target.value || null })}
+ className="input py-1 text-xs w-auto"
+ />
+
+ {hasFilters && (
+ {
+ setSearchInput("");
+ update({
+ status: null,
+ q: null,
+ cwd: null,
+ tool: null,
+ lane: null,
+ from: null,
+ to: null,
+ });
+ }}
+ className="text-fg-muted hover:text-fg-primary underline"
+ >
+ {t("clearFilters")}
+
+ )}
+
+
+ {error && (
+
+
+
+ {t("failedList")}: {error}
+
+
+ {t("common:retry")}
+
+
+ )}
+
+ {!loading && !error && filtered.length === 0 ? (
) : (
<>
-
+
@@ -437,37 +608,49 @@ export function Sessions() {
{paged.map((session) => (
navigate(`/sessions/${session.id}`)}
+ // Mouse convenience; the session name below is the real,
+ // focusable link (a click on a link or button inside wins).
+ onClick={(e) => {
+ if ((e.target as HTMLElement).closest("a,button")) return;
+ navigate(`/sessions/${session.id}`);
+ }}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-4 transition-colors cursor-pointer group"
>
-
+
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
-
+
{session.source && session.source !== "local" && (
{sourceLabels.get(session.source) || session.source}
)}
- {dashboardRunIds.has(session.id) && (
-
e.stopPropagation()}
- className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
- title={t("dashboardRunBadge", "Driven by Run page · click to open")}
- >
-
- {t("common:dashboardRun", "Run")}
-
- )}
+ {(() => {
+ // A live dashboard pane holds it: open that pane.
+ const run = runs.find((r) => r.sessionId === session.id);
+ const href = run ? workspaceHref(session, runs) : null;
+ return href ? (
+
+
+ {t("openInWorkspace")}
+
+ ) : null;
+ })()}
{session.id.slice(0, 12)}
diff --git a/client/src/pages/Workflows.tsx b/client/src/pages/Workflows.tsx
index 299394c6..941e3baf 100644
--- a/client/src/pages/Workflows.tsx
+++ b/client/src/pages/Workflows.tsx
@@ -76,6 +76,7 @@ import { useTranslation } from "react-i18next";
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
+import { useLiveRefresh } from "../hooks/useLiveRefresh";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
@@ -90,7 +91,7 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
type StatusFilter = "all" | "active" | "completed";
-export function Workflows() {
+export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
@@ -100,36 +101,49 @@ export function Workflows() {
const [statusFilter, setStatusFilter] = useState("all");
const [lastUpdated, setLastUpdated] = useState(null);
+ const [hasPipelines, setHasPipelines] = useState(false);
+ const drillInRef = useRef(null);
+ // A filter switch can overtake a slow poll; only the newest response lands.
+ const fetchSeq = useRef(0);
const fetchData = useCallback(async () => {
+ const seq = ++fetchSeq.current;
try {
- setError(null);
const result = await api.workflows.get(statusFilter);
+ if (seq !== fetchSeq.current) return;
+ setError(null);
setData(result);
setLastUpdated(new Date());
} catch (err) {
- setError(err instanceof Error ? err.message : t("failedLoad"));
+ if (seq === fetchSeq.current) setError(err instanceof Error ? err.message : t("failedLoad"));
} finally {
- setLoading(false);
+ if (seq === fetchSeq.current) setLoading(false);
}
}, [statusFilter]);
+ const selectSession = useCallback((id: string | null) => {
+ setSelectedSessionId(id);
+ // The drill-in sits far below the scatter; bring it into view.
+ if (id)
+ requestAnimationFrame(() =>
+ drillInRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" })
+ );
+ }, []);
+
useEffect(() => {
fetchData();
}, [fetchData]);
- // Auto-refresh on WebSocket events
- useEffect(() => {
- let debounceTimer: ReturnType;
- const handler = (_msg: WSMessage) => {
- clearTimeout(debounceTimer);
- debounceTimer = setTimeout(fetchData, 3000);
- };
- const unsub = eventBus.subscribe(handler);
- return () => {
- unsub();
- clearTimeout(debounceTimer);
- };
- }, [fetchData]);
+ // Auto-refresh on the WebSocket messages that change workflow data, throttled
+ // so a steady stream refreshes every few seconds instead of never.
+ useLiveRefresh(fetchData, {
+ throttleMs: 5000,
+ match: (msg: WSMessage) =>
+ msg.type === "session_created" ||
+ msg.type === "session_updated" ||
+ msg.type === "agent_created" ||
+ msg.type === "agent_updated" ||
+ msg.type === "workflow_upserted",
+ });
const handleRefresh = () => {
setLoading(true);
@@ -151,6 +165,7 @@ export function Workflows() {
return (
{/* Page Header */}
{/* Section 1: this dashboard's own pipelines */}
-
+ {/* Pipelines are dormant by default: the section stays mounted (so it can
+ report a template appearing) but hidden while none is loaded. */}
+
+
+
{/* Section 2: what drives those pipelines */}
{selectedNode && (
- {t("filteredBy")}
+ {t("selectedNode")}
{selectedNode}
@@ -255,7 +276,7 @@ export function Workflows() {
onClick={() => setSelectedNode(null)}
className="text-xs text-fg-muted hover:text-fg-secondary underline"
>
- {t("clearFilter")}
+ {t("clearSelection")}
)}
@@ -279,7 +300,7 @@ export function Workflows() {
subtitle={t("complexity.subtitle")}
infoKey="complexity"
>
-
+
{/* Section 7: Session Drill-In */}
-
- setSelectedSessionId(null)}
- onSelectSession={(id) => setSelectedSessionId(id)}
- />
-
+
+
+ setSelectedSessionId(null)}
+ onSelectSession={(id) => setSelectedSessionId(id)}
+ />
+
+
);
}
@@ -467,12 +490,15 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
// ── Page Header ──
function PageHeader({
+ embedded,
statusFilter,
onStatusFilterChange,
onRefresh,
onExport,
lastUpdated,
}: {
+ /** Inside Dashboard → Analytics → Workflow: the tab bar already names it. */
+ embedded?: boolean;
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
@@ -488,8 +514,10 @@ function PageHeader({
];
return (
-
-
+
+
diff --git a/client/src/pages/__tests__/Sessions.urlState.test.tsx b/client/src/pages/__tests__/Sessions.urlState.test.tsx
new file mode 100644
index 00000000..40275b9f
--- /dev/null
+++ b/client/src/pages/__tests__/Sessions.urlState.test.tsx
@@ -0,0 +1,121 @@
+/**
+ * @file Sessions.urlState.test.tsx
+ * @description The Sessions list keeps its filters in the URL: one filter
+ * change is one load at page 0, "Waiting" asks the server (`awaiting`) instead
+ * of downloading every active session, and a stale response never overwrites
+ * a newer one.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
+import { api } from "../../lib/api";
+import { Sessions } from "../Sessions";
+import type { Session } from "../../lib/types";
+
+function GoPlain() {
+ const nav = useNavigate();
+ return nav("/sessions")}>go-plain ;
+}
+
+function Where() {
+ const loc = useLocation();
+ return {loc.search} ;
+}
+
+const page = (sessions: Partial[], total = sessions.length) => ({
+ sessions: sessions as Session[],
+ total,
+ limit: 10,
+ offset: 0,
+});
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.spyOn(api.sessions, "facets").mockResolvedValue({ cwds: [] } as never);
+ vi.spyOn(api.remoteSources, "list").mockResolvedValue({ sources: [] } as never);
+ vi.spyOn(api.run, "list").mockResolvedValue({ items: [] });
+});
+
+describe("Sessions URL state", () => {
+ it("one filter click = one load at page 0; Waiting asks the server", async () => {
+ const list = vi.spyOn(api.sessions, "list").mockResolvedValue(page([]));
+ render(
+
+
+
+
+ );
+ await waitFor(() => expect(list).toHaveBeenCalled());
+ expect(list.mock.calls[list.mock.calls.length - 1]![0]).toMatchObject({ offset: 30 });
+ list.mockClear();
+
+ fireEvent.click(screen.getByRole("button", { name: "Waiting" }));
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
+ expect(list.mock.calls[0]![0]).toMatchObject({
+ status: "active",
+ awaiting: true,
+ offset: 0,
+ limit: 10,
+ });
+ expect(screen.getByTestId("where").textContent).toBe("?status=waiting");
+ });
+
+ it("drops a response that arrives after a newer one", async () => {
+ let resolveOld!: (v: ReturnType) => void;
+ const list = vi
+ .spyOn(api.sessions, "list")
+ .mockImplementationOnce(() => new Promise((r) => (resolveOld = r)))
+ .mockResolvedValue(
+ page([
+ { id: "new-1", name: "Fresh", status: "completed", started_at: "2026-01-01T00:00:00Z" },
+ ])
+ );
+ render(
+
+
+
+ );
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
+ fireEvent.click(screen.getByRole("button", { name: "Completed" }));
+ expect(await screen.findByText("Fresh")).toBeInTheDocument();
+ await act(async () => {
+ resolveOld(
+ page([
+ { id: "old-1", name: "Stale", status: "completed", started_at: "2026-01-01T00:00:00Z" },
+ ])
+ );
+ });
+ expect(screen.queryByText("Stale")).not.toBeInTheDocument();
+ });
+
+ it("an outside navigation that clears ?q= clears the box, and the old q is not written back", async () => {
+ vi.spyOn(api.sessions, "list").mockResolvedValue(page([]));
+ render(
+
+
+
+
+
+ );
+ expect(screen.getByRole("textbox", { name: /search sessions/i })).toHaveValue("abc");
+ fireEvent.click(screen.getByText("go-plain"));
+ await waitFor(() =>
+ expect(screen.getByRole("textbox", { name: /search sessions/i })).toHaveValue("")
+ );
+ await act(() => new Promise((r) => setTimeout(r, 400)));
+ expect(screen.getByTestId("where").textContent).toBe("");
+ });
+
+ it("a page past the end goes back to page 1", async () => {
+ vi.spyOn(api.sessions, "list").mockResolvedValue(page([], 5));
+ render(
+
+
+
+
+ );
+ await waitFor(() => expect(screen.getByTestId("where").textContent).toBe(""));
+ });
+});
diff --git a/server/__tests__/ui-audit-routes.test.js b/server/__tests__/ui-audit-routes.test.js
new file mode 100644
index 00000000..b4a9c24e
--- /dev/null
+++ b/server/__tests__/ui-audit-routes.test.js
@@ -0,0 +1,128 @@
+/**
+ * @file ui-audit-routes.test.js
+ * @description Contract tests for the read-route additions the dashboard UI audit
+ * needed: batched agents by `session_ids`, exact tool-call totals on session
+ * stats, session/agent labels joined onto the events list, and the
+ * `awaiting`/`from`/`to` filters on the sessions list.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+const { describe, it, before, after } = require("node:test");
+const assert = require("node:assert/strict");
+const path = require("path");
+const fs = require("fs");
+const os = require("os");
+
+const TEST_DB = path.join(os.tmpdir(), `ui-audit-test-${Date.now()}-${process.pid}.db`);
+process.env.DASHBOARD_DB_PATH = TEST_DB;
+process.env.DASHBOARD_LIVENESS_PROBE = "0";
+
+const { createApp, startServer } = require("../index");
+const { db } = require("../db");
+
+let server;
+let BASE;
+
+async function get(p) {
+ const res = await fetch(BASE + p);
+ return { status: res.status, body: await res.json() };
+}
+
+before(async () => {
+ server = await startServer(createApp(), 0);
+ BASE = `http://127.0.0.1:${server.address().port}`;
+ const s = db.prepare(
+ "INSERT INTO sessions (id, name, status, cwd, started_at, awaiting_input_since) VALUES (?, ?, ?, ?, ?, ?)"
+ );
+ s.run("ua-1", "First", "active", "/tmp/ua", "2026-01-10T10:00:00.000Z", "2026-01-10T10:05:00Z");
+ s.run("ua-2", "Second", "completed", "/tmp/ua", "2026-02-10T10:00:00.000Z", null);
+ s.run("ua-3", "Third", "active", "/tmp/ua", "2026-03-10T10:00:00.000Z", null);
+ const a = db.prepare(
+ "INSERT INTO agents (id, session_id, name, type, subagent_type) VALUES (?, ?, ?, ?, ?)"
+ );
+ a.run("ua-1-main", "ua-1", "Main", "main", null);
+ a.run("ua-2-main", "ua-2", "Main", "main", null);
+ a.run("ua-2-sub", "ua-2", "Explorer", "subagent", "Explore");
+ a.run("ua-3-main", "ua-3", "Main", "main", null);
+ const e = db.prepare(
+ "INSERT INTO events (session_id, agent_id, event_type, tool_name) VALUES (?, ?, ?, ?)"
+ );
+ // ua-1: 20 distinct tools, one PreToolUse + one PostToolUse each.
+ for (let i = 0; i < 20; i++) {
+ e.run("ua-1", "ua-1-main", "PreToolUse", `Tool${i}`);
+ e.run("ua-1", "ua-1-main", "PostToolUse", `Tool${i}`);
+ }
+ // ua-2: imported shape, PostToolUse only.
+ e.run("ua-2", "ua-2-sub", "PostToolUse", "Read");
+ e.run("ua-2", "ua-2-sub", "PostToolUse", "Read");
+});
+
+after(() => {
+ if (server) server.close();
+ if (db) db.close();
+ for (const f of [TEST_DB, TEST_DB + "-wal", TEST_DB + "-shm"]) {
+ try {
+ fs.unlinkSync(f);
+ } catch {
+ // ignore
+ }
+ }
+});
+
+describe("GET /api/agents?session_ids=", () => {
+ it("returns every agent of the named sessions in one call", async () => {
+ const { status, body } = await get("/api/agents?session_ids=ua-1,ua-2");
+ assert.equal(status, 200);
+ assert.deepEqual(body.agents.map((a) => a.id).sort(), ["ua-1-main", "ua-2-main", "ua-2-sub"]);
+ });
+ it("an empty list returns no agents", async () => {
+ const { body } = await get("/api/agents?session_ids=");
+ assert.deepEqual(body.agents, []);
+ });
+});
+
+describe("GET /api/sessions/:id/stats tool totals", () => {
+ it("counts every PreToolUse, not the top-15 list", async () => {
+ const { body } = await get("/api/sessions/ua-1/stats");
+ assert.equal(body.tools_used.length, 15);
+ assert.equal(body.tool_call_count, 20);
+ assert.equal(body.distinct_tool_count, 20);
+ });
+ it("falls back to PostToolUse when a session has no PreToolUse", async () => {
+ const { body } = await get("/api/sessions/ua-2/stats");
+ assert.equal(body.tool_call_count, 2);
+ assert.equal(body.distinct_tool_count, 1);
+ });
+});
+
+describe("GET /api/events labels", () => {
+ it("joins session name/cwd and agent name/type onto each row", async () => {
+ const { body } = await get("/api/events?session_id=ua-2");
+ assert.equal(body.total, 2);
+ const ev = body.events[0];
+ assert.equal(ev.session_name, "Second");
+ assert.equal(ev.session_cwd, "/tmp/ua");
+ assert.equal(ev.agent_name, "Explorer");
+ assert.equal(ev.agent_subagent_type, "Explore");
+ });
+});
+
+describe("GET /api/sessions filters", () => {
+ it("awaiting=1 keeps only active sessions waiting on the user", async () => {
+ const { body } = await get("/api/sessions?awaiting=1&q=ua-");
+ assert.deepEqual(
+ body.sessions.map((s) => s.id),
+ ["ua-1"]
+ );
+ assert.equal(body.total, 1);
+ });
+ it("from/to bound started_at", async () => {
+ const { body } = await get(
+ "/api/sessions?q=ua-&from=2026-02-01T00:00:00Z&to=2026-02-28T23:59:59Z"
+ );
+ assert.deepEqual(
+ body.sessions.map((s) => s.id),
+ ["ua-2"]
+ );
+ });
+});
diff --git a/server/db.js b/server/db.js
index 86267d6c..269609ba 100644
--- a/server/db.js
+++ b/server/db.js
@@ -1960,6 +1960,14 @@ const stmts = {
GROUP BY event_type
ORDER BY count DESC
`),
+ sessionToolCallTotals: db.prepare(`
+ SELECT
+ SUM(CASE WHEN event_type = 'PreToolUse' THEN 1 ELSE 0 END) as pre,
+ SUM(CASE WHEN event_type = 'PostToolUse' THEN 1 ELSE 0 END) as post,
+ COUNT(DISTINCT tool_name) as distinct_tools
+ FROM events
+ WHERE session_id = ? AND tool_name IS NOT NULL
+ `),
sessionToolUsageCounts: db.prepare(`
SELECT tool_name, COUNT(*) as count
FROM events
diff --git a/server/routes/agents.js b/server/routes/agents.js
index 23cced63..02e9b0bc 100644
--- a/server/routes/agents.js
+++ b/server/routes/agents.js
@@ -19,8 +19,26 @@ router.get("/", (req, res) => {
const session_id = req.query.session_id;
const sources = parseSources(req);
+ // `session_ids=a,b,c` = every agent of those sessions in ONE call (the
+ // Dashboard's active trees), capped so the IN list stays small.
+ const sessionIds =
+ typeof req.query.session_ids === "string"
+ ? [...new Set(req.query.session_ids.split(",").map((s) => s.trim()))]
+ .filter(Boolean)
+ .slice(0, 200)
+ : null;
+
let rows;
- if (session_id) {
+ if (sessionIds) {
+ rows = sessionIds.length
+ ? db
+ .prepare(
+ `SELECT * FROM agents WHERE session_id IN (${sessionIds.map(() => "?").join(",")})
+ ORDER BY started_at DESC LIMIT ?`
+ )
+ .all(...sessionIds, limit)
+ : [];
+ } else if (session_id) {
// A session belongs to exactly one source, so no extra source filter needed.
rows = stmts.listAgentsBySession.all(session_id);
} else if (sources) {
diff --git a/server/routes/events.js b/server/routes/events.js
index 07aad8c7..2b613b75 100644
--- a/server/routes/events.js
+++ b/server/routes/events.js
@@ -102,7 +102,15 @@ router.get("/", (req, res) => {
const { sql: whereSql, params: whereParams } = buildWhere(filters);
- const listSql = `SELECT * FROM events ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`;
+ // Labels for the row (session name/cwd, agent name/type) come with it, so a
+ // list page never downloads every session and agent just to name them.
+ const listSql = `SELECT events.*,
+ (SELECT name FROM sessions WHERE id = events.session_id) as session_name,
+ (SELECT cwd FROM sessions WHERE id = events.session_id) as session_cwd,
+ (SELECT lane_id FROM sessions WHERE id = events.session_id) as session_lane_id,
+ (SELECT name FROM agents WHERE id = events.agent_id) as agent_name,
+ (SELECT subagent_type FROM agents WHERE id = events.agent_id) as agent_subagent_type
+ FROM events ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`;
const countSql = `SELECT COUNT(*) as count FROM events ${whereSql}`;
const events = db.prepare(listSql).all(...whereParams, limit, offset);
diff --git a/server/routes/sessions.js b/server/routes/sessions.js
index f78af146..ec63fdea 100644
--- a/server/routes/sessions.js
+++ b/server/routes/sessions.js
@@ -193,6 +193,23 @@ router.get("/", (req, res) => {
);
params.push(Number(lane), Number(lane));
}
+ // `awaiting=1` = only sessions blocked on the user (an active session with
+ // `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
+ if (req.query.awaiting === "1") {
+ where.push("s.awaiting_input_since IS NOT NULL AND s.status = 'active'");
+ }
+ // `from`/`to` (ISO) bound `started_at`; an unparseable value is ignored.
+ for (const [key, op] of [
+ ["from", ">="],
+ ["to", "<="],
+ ]) {
+ const raw = req.query[key];
+ const d = typeof raw === "string" && raw ? new Date(raw) : null;
+ if (d && !Number.isNaN(d.getTime())) {
+ where.push(`s.started_at ${op} ?`);
+ params.push(d.toISOString());
+ }
+ }
// `archived=0|1` = only live / only archived; absent = both, as before.
if (req.query.archived === "0") where.push("s.archived_at IS NULL");
if (req.query.archived === "1") where.push("s.archived_at IS NOT NULL");
@@ -330,6 +347,9 @@ router.get("/:id/stats", (req, res) => {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
const tools = stmts.sessionToolUsageCounts.all(sessionId);
+ // Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
+ // A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
+ const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
const errors = stmts.sessionErrorCount.get(sessionId)?.count ?? 0;
const timeRange = stmts.sessionEventTimeRange.get(sessionId) || {};
const subagentTypes = stmts.sessionAgentTypeCounts.all(sessionId);
@@ -370,6 +390,8 @@ router.get("/:id/stats", (req, res) => {
total_events: totalEvents,
events_by_type: eventsByType,
tools_used: tools,
+ tool_call_count: toolTotals.pre > 0 ? toolTotals.pre : (toolTotals.post ?? 0),
+ distinct_tool_count: toolTotals.distinct_tools ?? 0,
error_count: errors,
first_event_at: timeRange.first_at ?? null,
last_event_at: timeRange.last_at ?? null,