feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers

- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy
  (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale
  chunk after a self-update reloads once (vite:preloadError too), any other
  error shows a Reload panel instead of a blank page.
- Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing
  debounces that froze under a steady event stream; refresh skips hidden
  tabs and refetches after a WebSocket reconnect. Monitor loads only while
  shown, with one batched GET /api/agents?session_ids= instead of N+1; a
  failing endpoint is an inline error, not a blank Dashboard.
- NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort
  and search live in the URL (Dashboard, Sessions, Activity, SessionDetail).
- Sessions: tool / workspace / date-range filters, CSV/JSON export
  (formula-neutralised), focusable row links, Open in workspace (the dead
  /run links are gone), stale responses dropped, error + Retry.
- Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday
  labels west of UTC (now averages), cache hit rate denominator, billable
  tokens vs cache reads, exact tool-call counts, "To" date = end of day,
  invalid ?from= no longer throws, recent list no longer shrinks to 15,
  stable message keys, transcript switch races.
- Light-theme-unreadable bg-black code panels and dark-only DAG fills use
  tokens; status configs and chart colours use theme tokens; Health tab
  and other literals translated.
- Server (additive): GET /api/agents?session_ids=, GET /api/sessions
  awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event
  rows carry session/agent labels.
This commit is contained in:
2026-10-05 13:22:04 +07:00
parent 8fb703c6db
commit 03f1673fce
63 changed files with 3879 additions and 1662 deletions
@@ -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>