feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers
- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale chunk after a self-update reloads once (vite:preloadError too), any other error shows a Reload panel instead of a blank page. - Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing debounces that froze under a steady event stream; refresh skips hidden tabs and refetches after a WebSocket reconnect. Monitor loads only while shown, with one batched GET /api/agents?session_ids= instead of N+1; a failing endpoint is an inline error, not a blank Dashboard. - NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort and search live in the URL (Dashboard, Sessions, Activity, SessionDetail). - Sessions: tool / workspace / date-range filters, CSV/JSON export (formula-neutralised), focusable row links, Open in workspace (the dead /run links are gone), stale responses dropped, error + Retry. - Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday labels west of UTC (now averages), cache hit rate denominator, billable tokens vs cache reads, exact tool-call counts, "To" date = end of day, invalid ?from= no longer throws, recent list no longer shrinks to 15, stable message keys, transcript switch races. - Light-theme-unreadable bg-black code panels and dark-only DAG fills use tokens; status configs and chart colours use theme tokens; Health tab and other literals translated. - Server (additive): GET /api/agents?session_ids=, GET /api/sessions awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event rows carry session/agent labels.
This commit is contained in:
@@ -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<SVGRectElement, unknown, null, undefined> | null = null;
|
||||
let bar: Selection<SVGRectElement, unknown, null, undefined> | 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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<PipelineStats | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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<typeof setTimeout>;
|
||||
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 <p className="text-xs text-status-danger">{error}</p>;
|
||||
if (!data) return null;
|
||||
@@ -71,7 +72,7 @@ export function LanePipelinePanel() {
|
||||
<select
|
||||
data-testid="pipeline-stats-lane-picker"
|
||||
aria-label={t("lanes.laneLabel")}
|
||||
className="max-w-[14rem] truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
|
||||
className="select max-w-[14rem] truncate py-0.5 text-xs"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
@@ -113,7 +114,7 @@ export function LanePipelinePanel() {
|
||||
<select
|
||||
data-testid="pipeline-stats-picker"
|
||||
aria-label={t("lanes.pipelineLabel")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
|
||||
className="select w-auto py-0.5 text-xs"
|
||||
value={selected.id}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
>
|
||||
|
||||
@@ -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<string, { fill: string; stroke: string; text: string }> = {
|
||||
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<DAGNode["kind"], string> = {
|
||||
session: "--accent",
|
||||
main: "--syn-builtin",
|
||||
subagent: "--status-success",
|
||||
nested: "--syn-property",
|
||||
outcome: "--accent",
|
||||
};
|
||||
const OUTCOME_VAR: Record<string, string> = {
|
||||
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<string, { fill: string; stroke: string; text: string }> =
|
||||
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<DAGNode["kind"], { id: string; stops: Array<{ offset: string; color: string }> }>;
|
||||
|
||||
// ── 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")}
|
||||
</span>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<div key={item.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
|
||||
style={{ background: item.color, border: `1px solid ${item.border}` }}
|
||||
/>
|
||||
<span className="text-[11px] text-fg-muted">{item.label}</span>
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
{t(`orchestration.legendItems.${item.key}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<span
|
||||
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
|
||||
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${mix("--accent", 30)}, ${tok("--accent")})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
|
||||
</div>
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,8 +56,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
|
||||
import { formatDateTime, formatDuration, formatModelName } from "../../lib/format";
|
||||
import type {
|
||||
SessionDrillIn as SessionDrillInData,
|
||||
DashboardEvent,
|
||||
@@ -157,14 +159,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
const { t } = useTranslation(["workflows", "common"]);
|
||||
const indentPx = depth * 20;
|
||||
const isMain = node.type === "main";
|
||||
const dur = node.ended_at
|
||||
? formatMs(
|
||||
Math.max(
|
||||
0,
|
||||
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
|
||||
)
|
||||
)
|
||||
: t("common:running");
|
||||
const dur = node.ended_at ? formatDuration(node.started_at, node.ended_at) : t("common:running");
|
||||
const sc = statusColor(node.status);
|
||||
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
|
||||
|
||||
@@ -451,14 +446,27 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
className="btn-ghost text-xs px-2 py-1"
|
||||
>
|
||||
{t("drillIn.openSession")}
|
||||
</Link>
|
||||
{session.lane_id != null && (
|
||||
<Link to={workspaceHref(session)!} className="btn-ghost text-xs px-2 py-1">
|
||||
{t("drillIn.openInWorkspace")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabBar active={activeTab} onChange={onTabChange} />
|
||||
</div>
|
||||
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allLoaded, setAllLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Typing fires overlapping requests; only the newest may write the list.
|
||||
const seqRef = useRef(0);
|
||||
|
||||
const fetchPage = useCallback((pageOffset: number, replace: boolean) => {
|
||||
// The search runs on the server (`q` matches id, name and folder), one page
|
||||
// at a time — never a download of every session to filter here.
|
||||
const fetchPage = useCallback((q: string, pageOffset: number, replace: boolean) => {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
api.sessions
|
||||
.list({ limit: PAGE_SIZE, offset: pageOffset })
|
||||
.list({ q: q || undefined, limit: PAGE_SIZE, offset: pageOffset })
|
||||
.then(({ sessions: page }) => {
|
||||
if (seq !== seqRef.current) return;
|
||||
setSessions((prev) => (replace ? page : [...prev, ...page]));
|
||||
setHasMore(page.length === PAGE_SIZE);
|
||||
setOffset(pageOffset + page.length);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load ALL sessions for search (once, lazily)
|
||||
const loadAllSessions = useCallback(() => {
|
||||
if (allLoaded) return;
|
||||
setAllLoaded(true);
|
||||
// Fetch large batch for search
|
||||
api.sessions
|
||||
.list({ limit: 5000, offset: 0 })
|
||||
.then(({ sessions: all }) => setAllSessions(all))
|
||||
.catch(() => {});
|
||||
}, [allLoaded]);
|
||||
|
||||
// Load first page when dropdown opens
|
||||
// (Re)load the first page while open, debounced on the search text.
|
||||
const query = search.trim();
|
||||
useEffect(() => {
|
||||
if (open && sessions.length === 0) {
|
||||
fetchPage(0, true);
|
||||
}
|
||||
}, [open, sessions.length, fetchPage]);
|
||||
|
||||
// When user starts typing, load all sessions for search
|
||||
useEffect(() => {
|
||||
if (search.trim().length > 0) {
|
||||
loadAllSessions();
|
||||
}
|
||||
}, [search, loadAllSessions]);
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => fetchPage(query, 0, true), query ? 250 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, query, fetchPage]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
@@ -536,13 +533,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// When searching, filter across ALL sessions; otherwise show paginated
|
||||
const filtered = search.trim()
|
||||
? (allSessions.length > 0 ? allSessions : sessions).filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
|
||||
})
|
||||
: sessions;
|
||||
const filtered = sessions;
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setOpen(false);
|
||||
@@ -551,7 +542,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
fetchPage(offset, false);
|
||||
fetchPage(query, offset, false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -560,7 +551,8 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
|
||||
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
|
||||
"focus-within:ring-2 focus-within:ring-accent/40",
|
||||
open ? "border-accent/40" : "border-border",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
@@ -573,6 +565,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={t("drillIn.searchPlaceholder")}
|
||||
aria-label={t("drillIn.searchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => {
|
||||
@@ -648,8 +641,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more - only show when not filtering client-side */}
|
||||
{!search.trim() && hasMore && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
|
||||
@@ -12,7 +12,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 { SkillUsage } from "../../lib/types";
|
||||
|
||||
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
@@ -33,17 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
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 <p className="text-xs text-status-danger">{error}</p>;
|
||||
if (!data) return null;
|
||||
|
||||
@@ -589,7 +589,7 @@ export function WorkflowRunsPanel({
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
||||
{t("runs.promptLabel")}
|
||||
</div>
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-surface-1 border border-border rounded p-2 max-h-48 overflow-auto">
|
||||
{fullPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -598,7 +598,7 @@ export function WorkflowRunsPanel({
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
||||
{t("runs.resultLabel")}
|
||||
</div>
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-surface-1 border border-border rounded p-2 max-h-96 overflow-auto">
|
||||
{fullResult}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user