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:
+236
-190
@@ -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>
|
||||
|
||||
+665
-465
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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(""));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user