Files
AgentHub/client/src/pages/ActivityFeed.tsx
T
nntrivi2001 ca821008e2 perf: v2.5.10 — ready for 10+ concurrent users, leaner locally
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated
browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max
2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server
RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli).

Server
- /api/analytics: the events-table scans run in the read worker
  (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes
  maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args
- /api/workflows/skill-usage: +event_type and a tool_name range instead of
  LIKE, so it uses idx_events_tool (784 ms → a few ms)
- idx_events_session_type_tool (session_id, event_type, tool_name,
  created_at) replaces idx_events_session_type: covering reads for session
  stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB
  cache_size and 256 MB mmap_size on both connections
- WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every
  DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable
  connection; a failed checkpoint restores the automatic one (hook COMMIT p95
  109 ms → 15 ms at 100 hooks/s)
- tmux: one list-sessions answers a whole GET /api/lanes and the heal beat
  (liveSessions), shared for 1 s and cleared by this process's new/kill —
  GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat
  skips tmux when nothing runs; attach options chained into 2 spawns
- hooks: TurnDuration/APIError deduped against one read per hook instead of a
  query per transcript entry (23 ms/hook on a 763-turn session); one lane
  scan per hook; hourly upserts skip rows that did not grow; frames are sent
  after the transaction commits (and after the pane binding); new_event
  carries its row id; a split pane's (slot 1–9) hooks no longer rebind the
  lane's session_id
- watchdog liveness probe runs ps asynchronously; transcript newline scan via
  indexOf; startup subagent backfill fingerprints the files it already checked
- pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB)
- static: hashed assets served from their pre-compressed .br/.gz sibling

Client
- hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes,
  lane cards, git header, changes panel, settings); no per-tab hourly
  /updates/check (the server already checks)
- refetch storms throttled (workspace tree/session list 3 s, /api/run only on
  a run-key change, notification pane lookup only when shown); WS reconnect
  jitter; terminal retry backoff 1.5 → 30 s, stops on 1008
- heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame;
  GET in-flight dedupe in request()
- lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi
  (only while enabled); only the active locale loads; Workspace chunk
  prefetched on a /workspace deep link
- Sagi: no work while disabled, pupil via CSS variables, memo avatar
- build: .br/.gz siblings, sourcemap hidden; service worker no longer caches
  /assets/ (cache v3 drops the old one)

Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook
exports, so a suite run from a linked worktree's pre-commit can no longer
commit into the real repository or set core.bare.

Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README,
docs/plans/perf/README.md (audit, checklist, results).
2026-10-07 13:24:15 +07:00

656 lines
27 KiB
TypeScript

/**
* @file ActivityFeed.tsx
* @description Real-time feed of agent events with server-driven filters and
* a right-click session menu per row (`SessionRowMenu`),
* batched pagination. Clicking a row toggles the inline EventDetail payload
* view; the "View session" Link navigates to the session page. Live events
* trigger a debounced, filter-aware refetch that preserves the user's
* accumulated page size.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../lib/dataScope`
* - `../components/StatusBadge`
* - `../components/EmptyState`
* - `../components/EventDetail`
* - `../components/EventFilters`
* - `../components/EventFiltersInfo`
* - `../components/Skeleton`
* - `../lib/event-grouping`
* - `../lib/format`
* - `../lib/types`
*
* ## Public surface
* - `ActivityFeed` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **ActivityFeed**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef, useMemo, useSyncExternalStore } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
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";
import { EventDetail } from "../components/EventDetail";
import { useContextMenu } from "../components/ContextMenu";
import { SessionRowMenu } from "../components/SessionRowMenu";
import {
EventFilters,
isEmptyFilters,
expandStatusToEventTypes,
filtersFromParams,
filtersToParams,
} from "../components/EventFilters";
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
import { Skeleton } from "../components/Skeleton";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { formatTime, formatDateShort, timeAgo, isoOrUndefined } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
const PAGE_SIZE = 50;
// 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 = 3000;
/** The agents on screen not asked about yet, and the sessions to ask for them. */
export function agentsToFetch(
events: { session_id: string; agent_id?: string | null }[],
asked: ReadonlySet<string>
): { agentIds: string[]; sessionIds: string[] } {
const fresh = events.filter((e) => e.agent_id && !asked.has(e.agent_id));
return {
agentIds: [...new Set(fresh.map((e) => e.agent_id as string))],
sessionIds: [...new Set(fresh.map((e) => e.session_id))],
};
}
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);
// 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 [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());
// 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 askedAgents = useRef(new Set<string>());
const pausedRef = useRef(paused);
pausedRef.current = paused;
const live = page === 0 && !paused;
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Convert UI filter state → API params. Status presets expand into
// event_type values and merge with any explicit event_type selection.
const apiParams = useMemo(() => {
const statusExpanded = expandStatusToEventTypes(filters.status);
const eventTypeMerged = Array.from(new Set<string>([...filters.event_type, ...statusExpanded]));
return {
event_type: eventTypeMerged.length > 0 ? eventTypeMerged : undefined,
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
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: isoOrUndefined(filters.from),
to: isoOrUndefined(filters.to),
};
}, [filters]);
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
// 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]);
// Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
useEffect(() => {
const { agentIds, sessionIds: missing } = agentsToFetch(events, askedAgents.current);
if (missing.length === 0) return;
for (const id of agentIds) askedAgents.current.add(id);
api.agents
.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 id of agentIds) askedAgents.current.delete(id);
});
}, [events]);
// 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(() => {
if (live) return;
return eventBus.subscribe((msg) => {
if (msg.type === "new_event") setNewCount((n) => n + 1);
});
}, [live]);
function resume() {
setPaused(false);
setNewCount(0);
if (page !== 0) setPage(0);
// Catch-up via filtered refresh so buffered non-matching events don't leak in.
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; 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) ?? 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,
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}
/>
)}
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
{!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">
{/* 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" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted">
{t("subtitle")}
{paused && (
<span className="ml-2 text-status-warning">{t("paused", { count: newCount })}</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => (paused ? resume() : setPaused(true))} className="btn-ghost">
{paused ? (
<>
<Play className="w-4 h-4" /> {t("resume")}
</>
) : (
<>
<Pause className="w-4 h-4" /> {t("pause")}
</>
)}
</button>
<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>
</div>
<div className="mb-3">
<EventFiltersInfo />
</div>
<div className="mb-4">
<EventFilters
value={filters}
onChange={setFilters}
sessionOptions={Array.from(sessionNameById.entries()).map(([id, label]) => ({
id,
label,
}))}
/>
</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}
title={isEmptyFilters(filters) ? t("noActivity") : t("common:eventFilters.noResults")}
description={isEmptyFilters(filters) ? t("noActivityDesc") : ""}
/>
) : (
<>
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[calc(100vh-260px)] min-h-[560px] overflow-y-auto overflow-x-auto">
{loading && events.length === 0
? Array.from({ length: 12 }).map((_, i) => (
<div
key={`sk-${i}`}
className="flex items-center px-5 py-3.5 gap-4"
aria-busy="true"
>
<Skeleton className="w-3.5 h-3.5" rounded="sm" />
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-16" rounded="full" />
<Skeleton className="h-3 w-48 flex-shrink-0" />
<Skeleton className="h-3 flex-1" />
</div>
))
: null}
{events.map((event, i) => {
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
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
if (event.id != null) toggleEvent(event.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"
>
<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">
{formatTime(event.created_at)}
</div>
<div className="text-[9px] text-fg-muted">
{formatDateShort(event.created_at)}
</div>
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
const sname = sessionNameById.get(event.session_id);
const project = projectByEventId.get(event.id) ?? null;
const origin = buildOriginLabel(
project,
sname ?? null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<div className="flex-1 min-w-0">
<p className="text-sm text-fg-secondary truncate">
{origin && (
<span
className="text-fg-muted mr-1"
title={`${event.session_id} · ${event.agent_id ?? ""}`}
>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</p>
</div>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono flex-shrink-0">
{event.tool_name}
</span>
)}
<span className="text-[11px] text-fg-muted flex-shrink-0 w-16 text-right">
{timeAgo(event.created_at)}
</span>
<Link
to={`/sessions/${event.session_id}`}
onClick={(e) => e.stopPropagation()}
title={t("viewSession")}
className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-md bg-surface-2 text-fg-secondary hover:text-accent hover:bg-accent/10 border border-border hover:border-accent/30 transition-colors flex-shrink-0 font-medium"
>
{t("viewSession")}
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{isOpen && <EventDetail event={event} />}
</div>
);
})}
</div>
</div>
{total > 0 && (
<div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-fg-muted">
{t("common:pagination.showing", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
total,
})}
</span>
<div className="flex items-center gap-1">
<button
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={t("firstPage")}
>
«
</button>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 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"
>
{t("common:pagination.previous")}
</button>
{(() => {
// Compact numbered page buttons: show up to 5 pages around
// the current page, with ellipses when appropriate.
const pages: (number | "...")[] = [];
const windowSize = 5;
let start = Math.max(0, page - Math.floor(windowSize / 2));
let end = Math.min(totalPages - 1, start + windowSize - 1);
start = Math.max(0, Math.min(start, end - windowSize + 1));
if (start > 0) {
pages.push(0);
if (start > 1) pages.push("...");
}
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) {
if (end < totalPages - 2) pages.push("...");
pages.push(totalPages - 1);
}
return pages.map((p, idx) =>
p === "..." ? (
<span
key={`ellipsis-${idx}`}
className="px-2 py-1.5 text-xs text-fg-muted select-none"
>
...
</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
aria-current={p === page ? "page" : undefined}
className={`min-w-[32px] px-2.5 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-colors ${
p === page
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`}
>
{p + 1}
</button>
)
);
})()}
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 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"
>
{t("common:pagination.next")}
</button>
<button
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={t("lastPage")}
>
»
</button>
</div>
</div>
)}
</>
)}
</div>
);
}