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
+236 -190
View File
@@ -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<string>();
export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) {
const eventCtx = useContextMenu<DashboardEvent>();
const { t } = useTranslation("activity");
const [events, setEvents] = useState<DashboardEvent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [filters, setFilters] = useState<EventFiltersValue>(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<string | null>(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<Set<number>>(() => 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<Map<string, string>>(() => 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<Map<string, string>>(
() => 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<Map<string, AgentInfo>>(() => new Map());
const knownAgentSessions = useRef(new Set<string>());
const bufferRef = useRef<DashboardEvent[]>([]);
const pausedRef = useRef(paused);
// Refs let the websocket handler read the latest filter/page without
// re-subscribing on every change.
const apiParamsRef = useRef<Record<string, unknown>>({});
const pageRef = useRef(0);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, string>();
const projectMap = new Map<string, string>();
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<string, AgentInfo>();
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<string, string>();
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<number, string | null>();
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 (
<div className="animate-fade-in">
{eventCtx.menu && (
<SessionRowMenu
session={{ id: eventCtx.menu.item }}
session={{
id: eventCtx.menu.item.session_id,
lane_id: eventCtx.menu.item.session_lane_id,
cwd: eventCtx.menu.item.session_cwd,
}}
x={eventCtx.menu.x}
y={eventCtx.menu.y}
onClose={eventCtx.close}
@@ -332,12 +340,17 @@ export function ActivityFeed() {
)}
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Activity className="w-4.5 h-4.5 text-accent" />
</div>
{!embedded && (
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Activity className="w-4.5 h-4.5 text-accent" />
</div>
)}
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{/* Under History's tab bar the tab already names the page. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
{t("title")}
</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
@@ -353,9 +366,7 @@ export function ActivityFeed() {
<p className="text-xs text-fg-muted">
{t("subtitle")}
{paused && (
<span className="ml-2 text-yellow-700 dark:text-yellow-400">
{t("paused", { count: bufferCount })}
</span>
<span className="ml-2 text-status-warning">{t("paused", { count: newCount })}</span>
)}
</p>
</div>
@@ -372,7 +383,18 @@ export function ActivityFeed() {
</>
)}
</button>
<button onClick={load} className="btn-ghost">
<button onClick={() => handleExport("csv")} className="btn-ghost" disabled={total === 0}>
<Download className="w-4 h-4" /> {t("exportCsv")}
</button>
<button onClick={() => handleExport("json")} className="btn-ghost" disabled={total === 0}>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button
onClick={() => load()}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
@@ -393,6 +415,29 @@ export function ActivityFeed() {
/>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 mb-4 border-status-danger/40 text-sm text-status-danger"
>
<span className="flex-1 min-w-0">
{t("failedLoad")}: {error}
</span>
<button onClick={() => load()} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{!paused && page > 0 && newCount > 0 && (
<button
onClick={() => 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 })}
</button>
)}
{!loading && events.length === 0 ? (
<EmptyState
icon={Activity}
@@ -422,25 +467,29 @@ export function ActivityFeed() {
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
<div key={event.id ?? i} className="animate-slide-up">
{/* The row is a plain container; its expand control is a real
button and "Session" a sibling link (no nested interactives). */}
<div
role="button"
tabIndex={0}
onClick={() => {
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"
>
<ChevronRight
className={`w-3.5 h-3.5 text-fg-muted transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`}
/>
<button
type="button"
onClick={() => 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"
>
<ChevronRight
className={`w-3.5 h-3.5 text-fg-muted transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
</button>
<div className="w-16 flex-shrink-0 text-right font-mono leading-tight">
<div className="text-[11px] text-fg-muted">
@@ -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")}
>
«
</button>
@@ -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")}
>
»
</button>
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -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 }))}
/>
<active.Panel />
>
{/* `embedded`: the tab bar names the view, so the panel drops its own title. */}
<active.Panel embedded />
</PageTabs>
</div>
);
}
+181 -124
View File
@@ -57,7 +57,7 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import { Link, useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
@@ -77,9 +77,14 @@ import {
ExternalLink,
Workflow,
Hourglass,
X,
Download,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { workspaceHref } from "../components/SessionRowMenu";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { AgentCard } from "../components/AgentCard";
import { SessionOverview } from "../components/SessionOverview";
@@ -97,9 +102,10 @@ import {
import { EventDetail } from "../components/EventDetail";
import {
EventFilters,
EMPTY_FILTERS,
isEmptyFilters,
expandStatusToEventTypes,
filtersFromParams,
filtersToParams,
} from "../components/EventFilters";
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
@@ -119,6 +125,8 @@ import {
fmtCostFull,
timeAgo,
formatModelName,
parseDate,
isoOrUndefined,
} from "../lib/format";
import type {
Session,
@@ -127,6 +135,7 @@ import type {
CostResult,
TranscriptInfo,
WorkflowRun,
WSMessage,
} from "../lib/types";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
@@ -136,7 +145,8 @@ const EVENTS_INITIAL_BATCH = 50;
const EVENTS_MORE_BATCH = 500;
// Live-refresh bounds - see ActivityFeed for rationale.
const EVENTS_MAX_REFRESH = 500;
const EVENTS_REFRESH_DEBOUNCE_MS = 500;
const EVENTS_REFRESH_THROTTLE_MS = 1000;
const DETAIL_TABS: DetailTab[] = ["agents", "conversation", "timeline"];
export function SessionDetail() {
const { id } = useParams<{ id: string }>();
@@ -149,17 +159,41 @@ export function SessionDetail() {
const [events, setEvents] = useState<DashboardEvent[]>([]);
const [eventsTotal, setEventsTotal] = useState(0);
const [eventsLoadingMore, setEventsLoadingMore] = useState(false);
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
// Tab and timeline filters live in the URL (replace), so a reload or a
// shared link reopens the same view.
const [params, setParams] = useSearchParams();
const paramKey = params.toString();
const filters = useMemo(() => filtersFromParams(new URLSearchParams(paramKey)), [paramKey]);
const setFilters = useCallback(
(f: EventFiltersValue) => setParams((prev) => filtersToParams(f, prev), { replace: true }),
[setParams]
);
const [cost, setCost] = useState<CostResult | null>(null);
const [loading, setLoading] = useState(true);
// True when this session is currently being driven by an in-flight Run
// handle on /run. Drives the "Open in Run page" banner up top.
const [isDashboardRun, setIsDashboardRun] = useState(false);
// Live dashboard runs: one holding THIS session drives the "open its pane" banner.
const [runs, setRuns] = useState<RunHandle[]>([]);
const isDashboardRun = runs.some((h) => h.sessionId === id && h.status === "running");
const [error, setError] = useState<string | null>(null);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(() => {
return new Set<string>();
});
const [activeTab, setActiveTab] = useState<DetailTab>("agents");
const tabParam = params.get("tab");
const activeTab: DetailTab = DETAIL_TABS.includes(tabParam as DetailTab)
? (tabParam as DetailTab)
: "agents";
const setActiveTab = useCallback(
(tab: DetailTab) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (tab === "agents") next.delete("tab");
else next.set("tab", tab);
return next;
},
{ replace: true }
),
[setParams]
);
// Keep tabs mounted once visited so switching between them doesn't unmount/
// remount their subtrees (which causes a perceptible flash on click).
const [visitedTabs, setVisitedTabs] = useState<Set<DetailTab>>(() => new Set(["agents"]));
@@ -184,8 +218,6 @@ export function SessionDetail() {
// Refs let the websocket handler access latest state without re-subscribing.
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
const eventsLoadedCountRef = useRef(0);
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
eventsLoadedCountRef.current = events.length;
const goBack = useCallback(() => {
const historyState =
@@ -208,34 +240,20 @@ export function SessionDetail() {
}, [transcriptNotFound]);
// Probe /api/run to see whether THIS session is currently being driven by
// an in-flight Run handle. If so, surface a banner so the user can hop
// back to /run instead of viewing this as a passive transcript.
// a dashboard pane. If so, a banner links straight to that pane.
const probeRuns = useCallback(() => {
// Defensive: tests mock the api module without a `run` namespace.
if (!api.run || typeof api.run.list !== "function") return;
api.run
.list()
.then((r) => setRuns(r.items))
.catch(() => undefined);
}, []);
useEffect(() => {
if (!id) return;
let cancelled = false;
const probe = () => {
// Defensive: tests mock the api module without a `run` namespace.
if (!api.run || typeof api.run.list !== "function") return;
api.run
.list()
.then((r) => {
if (cancelled) return;
const live = r.items.some((h) => h.sessionId === id && h.status === "running");
setIsDashboardRun(live);
})
.catch(() => undefined);
};
probe();
const t = setInterval(probe, 10000);
const unsub = eventBus.subscribe((msg) => {
if (msg.type === "run_status") probe();
});
return () => {
cancelled = true;
clearInterval(t);
unsub();
};
}, [id]);
probeRuns();
}, [probeRuns]);
// A pane starting/stopping re-broadcasts its lane (`lane_update`).
useLiveRefresh(probeRuns, { intervalMs: 10000, match: (msg) => msg.type === "lane_update" });
const load = useCallback(async () => {
if (!id) return;
@@ -350,7 +368,7 @@ export function SessionDetail() {
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
compactions.forEach((a, i) => {
const time = a.started_at
? new Date(a.started_at).toLocaleTimeString(undefined, {
? parseDate(a.started_at).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})
@@ -374,8 +392,8 @@ export function SessionDetail() {
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
q: filters.q || undefined,
from: filters.from ? new Date(filters.from).toISOString() : undefined,
to: filters.to ? new Date(filters.to).toISOString() : undefined,
from: isoOrUndefined(filters.from),
to: isoOrUndefined(filters.to),
};
}, [id, filters]);
@@ -497,52 +515,58 @@ export function SessionDetail() {
}
}, []);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg) => {
if (isRemoteDataRefreshMessage(msg)) {
load();
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
eventsRefreshTimerRef.current = setTimeout(() => {
eventsRefreshTimerRef.current = null;
refreshEventsWithPagination();
}, EVENTS_REFRESH_DEBOUNCE_MS);
return;
}
// Only this session's messages: every hook of every session broadcasts,
// and a reload per message re-read this page's whole session each time.
const d = msg.data as { id?: string; session_id?: string } | undefined;
const mine = d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
if (!mine) return;
if (
msg.type === "agent_created" ||
// Only this session's messages: every hook of every session broadcasts, and
// a reload per message re-read this page's whole session each time. Both
// refreshes are throttled (a steady stream still updates, never freezes) and
// re-run after a WebSocket reconnect.
const isMine = (msg: WSMessage) => {
const d = msg.data as { id?: string; session_id?: string } | undefined;
return d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
};
useLiveRefresh(load, {
match: (msg) =>
isRemoteDataRefreshMessage(msg) ||
((msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "session_updated"
) {
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
loadTimerRef.current = setTimeout(() => {
loadTimerRef.current = null;
load();
}, EVENTS_REFRESH_DEBOUNCE_MS);
msg.type === "session_updated") &&
isMine(msg)),
});
// A filter-aware refetch that preserves the current "Load more" size.
useLiveRefresh(refreshEventsWithPagination, {
throttleMs: EVENTS_REFRESH_THROTTLE_MS,
match: (msg) => isRemoteDataRefreshMessage(msg) || (msg.type === "new_event" && isMine(msg)),
});
async function exportEvents(kind: "csv" | "json") {
if (!eventApiParams) return;
try {
// The whole filtered timeline, a page at a time (the route caps a page at 500).
const all: DashboardEvent[] = [];
for (let offset = 0; ; offset += 500) {
const page = await api.events.list({ ...eventApiParams, limit: 500, offset });
all.push(...page.events);
if (page.events.length < 500 || all.length >= page.total) break;
}
if (msg.type === "new_event") {
// Debounce bursts into one filter-aware refetch that preserves the
// current "Load more" pagination size.
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
eventsRefreshTimerRef.current = setTimeout(() => {
eventsRefreshTimerRef.current = null;
refreshEventsWithPagination();
}, EVENTS_REFRESH_DEBOUNCE_MS);
}
});
return () => {
unsubscribe();
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
if (eventsRefreshTimerRef.current) {
clearTimeout(eventsRefreshTimerRef.current);
eventsRefreshTimerRef.current = null;
}
};
}, [id, load, refreshEventsWithPagination]);
const name = exportName(`session-${id?.slice(0, 8)}-events`, kind);
if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json");
else
downloadText(
name,
toCsv(all, [
"id",
"created_at",
"event_type",
"tool_name",
"agent_id",
"agent_name",
"summary",
]),
"text/csv"
);
} catch (err) {
toast.error(err);
}
}
if (loading) {
return (
@@ -596,14 +620,14 @@ export function SessionDetail() {
<div className="animate-fade-in space-y-8">
{/* Header */}
<div className="flex items-start gap-4">
<button onClick={goBack} className="btn-ghost mt-1">
<button onClick={goBack} className="btn-ghost mt-1" aria-label={t("detail.back")}>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold text-fg-primary">
<h1 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
</h1>
<SessionToolBadge tool={session.tool} />
<SessionStatusBadge
status={effectiveSessionStatus(session)}
@@ -647,8 +671,21 @@ export function SessionDetail() {
</div>
)}
</div>
{(() => {
const href = workspaceHref(session, runs);
return href ? (
<Link to={href} className="btn-ghost">
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
</Link>
) : null;
})()}
<SessionResumeButton session={session} />
<button onClick={load} className="btn-ghost">
<button
onClick={load}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
@@ -670,40 +707,34 @@ export function SessionDetail() {
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
urgent
? "border-status-warning/40 bg-status-warning/[0.08]"
: "border-yellow-500/25 bg-yellow-500/[0.05]"
: "border-status-warning/25 bg-status-warning/[0.05]"
}`}
>
<span
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
urgent
? "bg-status-warning/15 border-status-warning/30"
: "bg-yellow-500/10 border-yellow-500/25"
: "bg-status-warning/10 border-status-warning/25"
}`}
>
<ReasonIcon
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-status-warning"}`}
/>
</span>
<div className="flex-1 min-w-0">
<div
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-status-warning"}`}
>
{t("detail.waitingBanner.title")}
{cfg && (
<span
className={
urgent
? "text-status-warning/90"
: "text-yellow-700/80 dark:text-yellow-300/80"
}
>
<span className={urgent ? "text-status-warning/90" : "text-status-warning/80"}>
{" · "}
{t(cfg.labelKey)}
</span>
)}
</div>
<div
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-status-warning/70"}`}
>
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
</div>
@@ -711,12 +742,12 @@ export function SessionDetail() {
{session.awaiting_input_since && (
<span
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
urgent ? "text-status-warning/80" : "text-status-warning/80"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${
urgent ? "bg-status-warning" : "bg-yellow-400"
urgent ? "bg-status-warning" : "bg-status-warning"
}`}
aria-hidden="true"
/>
@@ -727,9 +758,9 @@ export function SessionDetail() {
);
})()}
{isDashboardRun && (
{isDashboardRun && workspaceHref(session, runs) && (
<Link
to={`/run?session=${encodeURIComponent(id || "")}`}
to={workspaceHref(session, runs)!}
className="flex items-center gap-3 rounded-lg border border-status-success/30 bg-status-success/[0.06] hover:bg-status-success/[0.12] hover:border-status-success/50 px-4 py-2.5 transition-colors group"
>
<span className="w-7 h-7 rounded-md bg-status-success/15 border border-status-success/30 inline-flex items-center justify-center flex-shrink-0">
@@ -737,13 +768,10 @@ export function SessionDetail() {
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-status-success">
{t("detail.dashboardRun.title", "This session is being driven from the Run page")}
{t("detail.dashboardRun.title")}
</div>
<div className="text-[11px] text-status-success/70">
{t(
"detail.dashboardRun.body",
"Send follow-ups, watch streaming output, or stop the run from there."
)}
{t("detail.dashboardRun.body")}
</div>
</div>
<ExternalLink className="w-4 h-4 text-status-success/70 group-hover:text-status-success flex-shrink-0" />
@@ -759,7 +787,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "agents"
? "border-violet-500 text-violet-700 dark:text-violet-400"
? "border-accent text-accent"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -773,7 +801,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "conversation"
? "border-violet-500 text-violet-700 dark:text-violet-400"
? "border-accent text-accent"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -787,7 +815,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "timeline"
? "border-violet-500 text-violet-700 dark:text-violet-400"
? "border-accent text-accent"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -804,8 +832,9 @@ export function SessionDetail() {
<button
onClick={() => setTranscriptNotFound(false)}
className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
aria-label={t("common:dismiss")}
>
<List className="w-3.5 h-3.5" />
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
@@ -817,7 +846,7 @@ export function SessionDetail() {
{workflows.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<Workflow className="w-3.5 h-3.5 text-syntax-keyword" />
{wfT("runs.sessionTitle")}
<span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3>
@@ -830,7 +859,7 @@ export function SessionDetail() {
) : (
<>
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<Bot className="w-3.5 h-3.5 text-syntax-keyword" />
{t("detail.agents")}
<span className="text-fg-muted font-mono">· {agents.length}</span>
</h3>
@@ -896,7 +925,11 @@ export function SessionDetail() {
<button
onClick={toggleExpanded}
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
aria-label={
isExpanded
? t("detail.collapseSubagents")
: t("detail.expandSubagents")
}
aria-expanded={isExpanded}
>
{isExpanded ? (
@@ -908,7 +941,7 @@ export function SessionDetail() {
)}
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
<GitBranch className="w-3 h-3 text-syntax-keyword flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
@@ -938,7 +971,7 @@ export function SessionDetail() {
{/* Recursive children (collapsible) */}
{hasChildren && isExpanded && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-violet-500/20 pl-3">
<div className="ml-6 mt-1 space-y-1 border-l-2 border-syntax-keyword/20 pl-3">
{children.map((child) =>
renderAgentNode(child, depth + 1, childAncestors)
)}
@@ -951,7 +984,7 @@ export function SessionDetail() {
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
className="ml-7 mt-1 text-[11px] text-syntax-keyword hover:text-fg-primary transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
</button>
@@ -1074,8 +1107,24 @@ export function SessionDetail() {
{visitedTabs.has("timeline") && (
<div hidden={activeTab !== "timeline"}>
<div className="mb-3">
<EventFiltersInfo />
<div className="mb-3 flex flex-wrap items-start gap-2">
<div className="flex-1 min-w-0">
<EventFiltersInfo />
</div>
<button
onClick={() => exportEvents("csv")}
className="btn-ghost text-xs"
disabled={eventsTotal === 0}
>
<Download className="w-3.5 h-3.5" /> {t("exportCsv")}
</button>
<button
onClick={() => exportEvents("json")}
className="btn-ghost text-xs"
disabled={eventsTotal === 0}
>
<Download className="w-3.5 h-3.5" /> {t("exportJson")}
</button>
</div>
<div className="mb-3">
<EventFilters
@@ -1096,7 +1145,15 @@ export function SessionDetail() {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
<div key={key}>
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
+334 -151
View File
@@ -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<Session>();
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<string, string | null>) =>
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<Session[]>([]);
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<string | null>(null);
const [exporting, setExporting] = useState(false);
const [directories, setDirectories] = useState<string[]>([]);
// 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<Map<string, string>>(() => 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<Set<string>>(new Set());
// Live dashboard runs, so a row a pane holds links straight to that pane.
const [runs, setRuns] = useState<RunHandle[]>([]);
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<string>();
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() {
)}
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<FolderOpen className="w-4.5 h-4.5 text-accent" />
</div>
{!embedded && (
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<FolderOpen className="w-4.5 h-4.5 text-accent" />
</div>
)}
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{/* Under History's tab bar the tab already names the page. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
{t("title")}
</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
@@ -304,13 +363,31 @@ export function Sessions() {
</div>
<p className="text-xs text-fg-muted">
{t("sessionCount", { count: total })}
{filter ? ` ${filter}` : ""}
{filter
? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}`
: ""}
</p>
</div>
</div>
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleExport("csv")}
className="btn-ghost"
disabled={exporting || total === 0}
>
<Download className="w-4 h-4" /> {t("exportCsv")}
</button>
<button
onClick={() => handleExport("json")}
className="btn-ghost"
disabled={exporting || total === 0}
>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
</div>
{/* Filters */}
@@ -321,6 +398,7 @@ export function Sessions() {
<input
type="text"
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="input w-full pl-10"
@@ -331,17 +409,17 @@ export function Sessions() {
<div className="relative shrink-0 w-[180px]">
<select
value={cwd}
onChange={(e) => 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"
>
<option value="">All Directories</option>
<option value="">{t("allDirectories")}</option>
{directories.map((d) => (
<option key={d} value={d} title={d}>
{truncate(d, 30)}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
</div>
{/* Sort Controls */}
@@ -349,22 +427,23 @@ export function Sessions() {
<div className="relative flex-1">
<select
value={sortBy}
onChange={(e) => 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"
>
<option value="time">Sort by Time ({sortDesc ? "Newest" : "Oldest"})</option>
<option value="time">{sortDesc ? t("sortTimeDesc") : t("sortTimeAsc")}</option>
<option value="duration">
Sort by Duration ({sortDesc ? "Longest" : "Shortest"})
{sortDesc ? t("sortDurationDesc") : t("sortDurationAsc")}
</option>
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
<option value="price">{sortDesc ? t("sortPriceDesc") : t("sortPriceAsc")}</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted pointer-events-none" />
</div>
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={() => 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 ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
</button>
@@ -375,7 +454,8 @@ export function Sessions() {
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => 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() {
</div>
</div>
{!loading && filtered.length === 0 ? (
{/* Tool / workspace / date range */}
<div className="flex flex-wrap items-center gap-3 -mt-3 mb-6 text-xs">
<select
value={tool}
onChange={(e) => update({ tool: e.target.value || null })}
aria-label={t("filterTool")}
className="select py-1 text-xs w-auto"
>
<option value="">{t("allTools")}</option>
{tools
.filter((x) => x.installed || x.id === tool)
.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<select
value={lane}
onChange={(e) => update({ lane: e.target.value || null })}
aria-label={t("filterWorkspace")}
className="select py-1 text-xs w-auto max-w-[16rem]"
>
<option value="">{t("allWorkspaces")}</option>
{lanes.map((l) => (
<option key={l.id} value={String(l.id)}>
{l.title || l.cwd}
</option>
))}
</select>
<label className="flex items-center gap-1.5 text-fg-muted">
{t("dateFrom")}
<input
type="date"
value={from}
max={to || undefined}
onChange={(e) => update({ from: e.target.value || null })}
className="input py-1 text-xs w-auto"
/>
</label>
<label className="flex items-center gap-1.5 text-fg-muted">
{t("dateTo")}
<input
type="date"
value={to}
min={from || undefined}
onChange={(e) => update({ to: e.target.value || null })}
className="input py-1 text-xs w-auto"
/>
</label>
{hasFilters && (
<button
onClick={() => {
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")}
</button>
)}
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 mb-4 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("failedList")}: {error}
</span>
<button onClick={load} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{!loading && !error && filtered.length === 0 ? (
<EmptyState
icon={FolderOpen}
title={t("noSessions")}
description={search || filter || cwd ? t("noSessionsDesc") : t("noSessionsHint")}
description={hasFilters ? t("noSessionsDesc") : t("noSessionsHint")}
/>
) : (
<>
<div className="card overflow-x-auto">
<div
className="card overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("tableRegion")}
>
<table className="w-full min-w-[800px]">
<thead>
<tr className="border-b border-border text-left">
@@ -437,37 +608,49 @@ export function Sessions() {
{paged.map((session) => (
<tr
key={session.id}
onClick={() => 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"
>
<td className="px-5 py-4">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-fg-secondary">
<Link
to={`/sessions/${session.id}`}
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
>
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</p>
</Link>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source}
</span>
)}
{dashboardRunIds.has(session.id) && (
<Link
to={`/run?session=${encodeURIComponent(session.id)}`}
onClick={(e) => 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")}
>
<Play className="w-2.5 h-2.5" />
{t("common:dashboardRun", "Run")}
</Link>
)}
{(() => {
// 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 ? (
<Link
to={href}
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("openInWorkspaceTitle")}
>
<Play className="w-2.5 h-2.5" />
{t("openInWorkspace")}
</Link>
) : null;
})()}
</div>
<p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)}
+65 -37
View File
@@ -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<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
@@ -100,36 +101,49 @@ export function Workflows() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [hasPipelines, setHasPipelines] = useState(false);
const drillInRef = useRef<HTMLDivElement>(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<typeof setTimeout>;
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 (
<div className="space-y-6">
<PageHeader
embedded={embedded}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
@@ -173,6 +188,7 @@ export function Workflows() {
return (
<div className="space-y-6">
<PageHeader
embedded={embedded}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
@@ -196,6 +212,7 @@ export function Workflows() {
<div className="space-y-6">
{/* Page Header */}
<PageHeader
embedded={embedded}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
@@ -207,9 +224,13 @@ export function Workflows() {
<WorkflowStats stats={data.stats} />
{/* Section 1: this dashboard's own pipelines */}
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
<LanePipelinePanel />
</Section>
{/* Pipelines are dormant by default: the section stays mounted (so it can
report a template appearing) but hidden while none is loaded. */}
<div className={hasPipelines ? undefined : "hidden"}>
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
<LanePipelinePanel onAvailability={setHasPipelines} />
</Section>
</div>
{/* Section 2: what drives those pipelines */}
<Section
@@ -247,7 +268,7 @@ export function Workflows() {
/>
{selectedNode && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-fg-muted">{t("filteredBy")}</span>
<span className="text-xs text-fg-muted">{t("selectedNode")}</span>
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
{selectedNode}
</span>
@@ -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")}
</button>
</div>
)}
@@ -279,7 +300,7 @@ export function Workflows() {
subtitle={t("complexity.subtitle")}
infoKey="complexity"
>
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
<SessionComplexityScatter data={data.complexity} onSessionClick={selectSession} />
</Section>
<Section
@@ -293,18 +314,20 @@ export function Workflows() {
</div>
{/* Section 7: Session Drill-In */}
<Section
number={7}
title={t("drillIn.title")}
subtitle={t("drillIn.subtitle")}
infoKey="drillIn"
>
<SessionDrillIn
sessionId={selectedSessionId}
onClose={() => setSelectedSessionId(null)}
onSelectSession={(id) => setSelectedSessionId(id)}
/>
</Section>
<div ref={drillInRef} className="scroll-mt-4">
<Section
number={7}
title={t("drillIn.title")}
subtitle={t("drillIn.subtitle")}
infoKey="drillIn"
>
<SessionDrillIn
sessionId={selectedSessionId}
onClose={() => setSelectedSessionId(null)}
onSelectSession={(id) => setSelectedSessionId(id)}
/>
</Section>
</div>
</div>
);
}
@@ -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 (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div
className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 ${embedded ? "sm:justify-end" : "justify-between"}`}
>
<div className={embedded ? "hidden" : "flex items-center gap-3"}>
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Workflow className="w-4.5 h-4.5 text-accent" />
</div>
@@ -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ĩ <vinnt@smartgift.vn>
*/
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 <button onClick={() => nav("/sessions")}>go-plain</button>;
}
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.search}</span>;
}
const page = (sessions: Partial<Session>[], 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(
<MemoryRouter initialEntries={["/sessions?page=3"]}>
<Sessions />
<Where />
</MemoryRouter>
);
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<typeof page>) => 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(
<MemoryRouter initialEntries={["/sessions"]}>
<Sessions />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={["/sessions?q=abc"]}>
<Sessions />
<GoPlain />
<Where />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={["/sessions?page=7"]}>
<Sessions />
<Where />
</MemoryRouter>
);
await waitFor(() => expect(screen.getByTestId("where").textContent).toBe(""));
});
});