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>
|
||||
|
||||
Reference in New Issue
Block a user