diff --git a/client/src/App.tsx b/client/src/App.tsx index d9f8dd35..f5c40cc0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -40,9 +40,9 @@ * - `./components/SplashScreen` * - `./pages/Dashboard` * - `./pages/History` - * - `./pages/SessionDetail` - * - `./pages/SettingsHub` - * - `./pages/Workspace` + * - `./pages/SessionDetail` (lazy) + * - `./pages/SettingsHub` (lazy) + * - `./pages/Workspace` (lazy) * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. @@ -54,23 +54,57 @@ * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { useCallback } from "react"; +import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom"; +import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Layout } from "./components/Layout"; import { DocumentTitle } from "./components/DocumentTitle"; import { SplashScreen } from "./components/SplashScreen"; import { GlobalTooltip } from "./components/Tooltip"; +import { Toaster } from "./components/Toaster"; +import { ConfirmHost } from "./components/ConfirmHost"; +import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary"; import { Dashboard } from "./pages/Dashboard"; import { History } from "./pages/History"; -import { SessionDetail } from "./pages/SessionDetail"; -import { SettingsHub } from "./pages/SettingsHub"; -import { Workspace } from "./pages/Workspace"; import { NotFound } from "./pages/NotFound"; import { useWebSocket } from "./hooks/useWebSocket"; import { useNotifications } from "./hooks/useNotifications"; import { eventBus } from "./lib/eventBus"; import type { WSMessage } from "./lib/types"; +// The heavy pages (xterm, the diff viewer, the settings editors) load on first +// visit instead of in the initial bundle. +const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace }))); +const SettingsHub = lazy(() => + import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub })) +); +const SessionDetail = lazy(() => + import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })) +); + +function PageFallback() { + const { t } = useTranslation(); + return ( +
+ {t("common:loading")} +
+ ); +} + +function Page({ children }: { children: ReactNode }) { + return ( + + }>{children} + + ); +} + +/** Keyed by the id so moving to another session starts from fresh state. */ +function SessionDetailRoute() { + const { id } = useParams(); + return ; +} + /** * Application root component mounted by {@link main.tsx}. * @returns Routed dashboard UI inside `BrowserRouter`. @@ -83,19 +117,39 @@ export default function App() { const { connected } = useWebSocket(onMessage); useNotifications(); + // A self-update replaced the hashed assets this tab still references: Vite + // reports the failed preload here; reload once (guarded against loops). + useEffect(() => { + const onPreloadError = (e: Event) => { + if (reloadOnce()) e.preventDefault(); + }; + window.addEventListener("vite:preloadError", onPreloadError); + return () => window.removeEventListener("vite:preloadError", onPreloadError); + }, []); + return ( <> {/* One renderer gives every `title` in the app the designed bubble — see components/Tooltip.tsx for why it is not a wrapper component. */} + {/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */} + + }> } /> } /> - } /> + + + + } + /> } /> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> } /> diff --git a/client/src/components/AgentCard.tsx b/client/src/components/AgentCard.tsx index a6beae0d..ae945a87 100644 --- a/client/src/components/AgentCard.tsx +++ b/client/src/components/AgentCard.tsx @@ -49,7 +49,8 @@ import { useTranslation } from "react-i18next"; import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { memo, useMemo } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { AgentStatusBadge } from "./StatusBadge"; import { useContextMenu } from "./ContextMenu"; import { SessionRowMenu } from "./SessionRowMenu"; @@ -82,7 +83,14 @@ interface AgentCardProps { onClick?: () => void; } -export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { +/** Memoized: the Dashboard re-renders on every live update, and a card only + * changes with its own agent/session. */ +export const AgentCard = memo(function AgentCard({ + agent, + session, + label, + onClick, +}: AgentCardProps) { const navigate = useNavigate(); const { t } = useTranslation("kanban"); const ctx = useContextMenu(); @@ -119,15 +127,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { // not the parent session's — see issue #185). Use it everywhere this card // shows a model so a Haiku QA agent under an Opus orchestrator reads as // Haiku, not Opus. Falls back to the session model only for the main agent. - let subagentModel: string | null = null; - if (!isMain && agent.metadata) { + const subagentModel = useMemo(() => { + if (isMain || !agent.metadata) return null; try { const parsed = JSON.parse(agent.metadata) as { model?: string }; - subagentModel = parsed?.model ? formatModelName(parsed.model) : null; + return parsed?.model ? formatModelName(parsed.model) : null; } catch { - subagentModel = null; + return null; } - } + }, [isMain, agent.metadata]); // The model badge (footer) must reflect THIS card's agent: the session model // for main, the subagent's own model for subagents. const displayModel = isMain ? model : subagentModel; @@ -143,15 +151,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { // "29 agents" looks like it should equal a 29-subagent stat when the session // actually has 28 subagents + 1 main. const subagentCount = Math.max(0, agentCount - 1); - let sessionTurns = 0; - if (isMain && session?.metadata) { + const sessionTurns = useMemo(() => { + if (!isMain || !session?.metadata) return 0; try { const m = JSON.parse(session.metadata) as { turn_count?: number }; - if (typeof m?.turn_count === "number") sessionTurns = m.turn_count; + return typeof m?.turn_count === "number" ? m.turn_count : 0; } catch { - sessionTurns = 0; + return 0; } - } + }, [isMain, session?.metadata]); const subtitle = isMain ? [ cwdBase, @@ -162,13 +170,17 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { .join(" · ") || null : [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null; - function handleClick() { - if (onClick) { - onClick(); - } else { - navigate(`/sessions/${agent.session_id}`); - } + const sessionHref = `/sessions/${agent.session_id}`; + // The whole card is clickable with the mouse; the title is the focusable + // control (a real link, or a button when the caller handles the click). + function handleClick(e: React.MouseEvent) { + if ((e.target as HTMLElement).closest("a,button")) return; + if (onClick) onClick(); + else navigate(sessionHref); } + const titleText = isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name; + const titleClass = + "block text-sm font-medium text-fg-secondary truncate text-left max-w-full rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"; return (
ctx.open(e, true)} className={`card-hover p-4 cursor-pointer overflow-hidden ${ isWaiting - ? "border-l-2 border-l-yellow-500/60" + ? "border-l-2 border-l-status-warning/60" : isActive - ? "border-l-2 border-l-emerald-500/50" + ? "border-l-2 border-l-status-success/50" : "" }`} > @@ -186,21 +198,25 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
{isMain ? : }
-

- {/* Auto-generated main-agent titles (e.g. "Main Agent - Session - 229d93fd" or "Main Agent - work - e3f8e613") swap the - placeholder for the real session name when one exists; custom - (sub)agent names are left untouched. */} - {isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name} -

+ {/* Auto-generated main-agent titles (e.g. "Main Agent - Session + 229d93fd" or "Main Agent - work - e3f8e613") swap the + placeholder for the real session name when one exists; custom + (sub)agent names are left untouched. */} + {onClick ? ( + + ) : ( + + {titleText} + + )} {subtitle &&

{subtitle}

}
@@ -251,7 +267,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { {timeAgo(agent.updated_at || agent.started_at)} )} - + {realSessionName && {realSessionName} ·} {agent.session_id.slice(0, 8)} @@ -266,4 +282,4 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { )}
); -} +}); diff --git a/client/src/components/DateTimePicker.tsx b/client/src/components/DateTimePicker.tsx index ea0da535..157c8887 100644 --- a/client/src/components/DateTimePicker.tsx +++ b/client/src/components/DateTimePicker.tsx @@ -43,6 +43,7 @@ * ----------------------------------------------------------------------------- */ import React, { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react"; interface DateTimePickerProps { @@ -52,16 +53,22 @@ interface DateTimePickerProps { className?: string; "aria-label"?: string; title?: string; + /** A "to" bound: picking a day with no time yet means the END of that day + * (23:59), not its first minute. */ + endOfDay?: boolean; } export function DateTimePicker({ value, onChange, - placeholder = "Select date & time", + placeholder, className = "", "aria-label": ariaLabel, title, + endOfDay = false, }: DateTimePickerProps) { + const { t, i18n } = useTranslation("sessions"); + const locale = i18n.resolvedLanguage ?? i18n.language; const [isOpen, setIsOpen] = useState(false); const [alignment, setAlignment] = useState<"left" | "right">("left"); const containerRef = useRef(null); @@ -97,9 +104,18 @@ export function DateTimePicker({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [isOpen]); + const formatDisplay = (d: Date | null) => { if (!d || isNaN(d.getTime())) return ""; - return d.toLocaleString(undefined, { + return d.toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", @@ -112,7 +128,9 @@ export function DateTimePicker({ if (dateObj && !isNaN(dateObj.getTime())) { newDate.setHours(dateObj.getHours(), dateObj.getMinutes()); } else { - newDate.setHours(0, 0); // Default midnight + // Default: midnight, or 23:59 for an end bound so the day is included. + if (endOfDay) newDate.setHours(23, 59, 59, 999); + else newDate.setHours(0, 0); } updateValue(newDate); }; @@ -135,7 +153,9 @@ export function DateTimePicker({ const day = String(d.getDate()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); - onChange(`${y}-${mo}-${day}T${h}:${mi}`); + // An end bound carries its :59.999 so the whole last minute is included. + const tail = endOfDay ? ":59.999" : ""; + onChange(`${y}-${mo}-${day}T${h}:${mi}${tail}`); }; const clearValue = (e: React.MouseEvent) => { @@ -168,10 +188,15 @@ export function DateTimePicker({ key={i} type="button" onClick={() => handleDateClick(i)} + aria-label={new Date(viewDate.getFullYear(), viewDate.getMonth(), i).toLocaleDateString( + locale, + { weekday: "long", year: "numeric", month: "long", day: "numeric" } + )} + aria-pressed={isSelected} className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors ${ isSelected - ? "bg-accent text-white font-medium" + ? "bg-accent-solid text-white font-medium" : isToday ? "bg-surface-3 text-accent font-medium" : "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary" @@ -193,20 +218,27 @@ export function DateTimePicker({ type="button" onClick={() => setIsOpen(!isOpen)} aria-label={ariaLabel} + aria-expanded={isOpen} + aria-haspopup="dialog" title={title} - className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`} + className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left ${dateObj ? "pr-7" : ""}`} > - {dateObj ? formatDisplay(dateObj) : placeholder} + {dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))} - {dateObj && ( - - )} + {/* A sibling, not a child: a button inside a button is not reachable by keyboard. */} + {dateObj && ( + + )} {isOpen && (
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)) } + aria-label={t("datePicker.prevMonth")} className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary" > - {viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })} + {viewDate.toLocaleString(locale, { month: "long", year: "numeric" })} +
+ ); + } +} diff --git a/client/src/components/SessionOverview.tsx b/client/src/components/SessionOverview.tsx index 54b2c30e..7abf2ae8 100644 --- a/client/src/components/SessionOverview.tsx +++ b/client/src/components/SessionOverview.tsx @@ -65,9 +65,11 @@ import { Bot, } from "lucide-react"; import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; +import { useLiveRefresh } from "../hooks/useLiveRefresh"; +import { useTranslation } from "react-i18next"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; -import { fmt, formatDuration } from "../lib/format"; +import { fmt, formatDuration, parseDate } from "../lib/format"; +import { effectiveSessionStatus } from "../lib/types"; import { styleForTool } from "./conversation/toolStyle"; import type { Agent, Session, SessionStats } from "../lib/types"; @@ -76,8 +78,8 @@ interface SessionOverviewProps { agents: Agent[]; } -/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */ -const REFRESH_DEBOUNCE_MS = 600; +/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */ +const REFRESH_THROTTLE_MS = 1000; /** Compact tile used in the top stat row. */ function StatTile({ @@ -93,24 +95,25 @@ function StatTile({ icon: React.ReactNode; tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue"; }) { + // Theme tokens only: status colours for states, syntax hues for categories. const palette = { default: "border-surface-3 bg-surface-2 text-fg-secondary", - violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200", + violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword", emerald: "border-status-success/20 bg-status-success/5 text-status-success", amber: "border-status-warning/20 bg-status-warning/5 text-status-warning", - rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200", - cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200", - blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300", + rose: "border-status-danger/20 bg-status-danger/5 text-status-danger", + cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property", + blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin", }[tone]; const iconTone = { default: "text-fg-muted", - violet: "text-violet-700 dark:text-violet-400", + violet: "text-syntax-keyword", emerald: "text-status-success", amber: "text-status-warning", - rose: "text-rose-700 dark:text-rose-400", - cyan: "text-cyan-700 dark:text-cyan-400", - blue: "text-blue-500", + rose: "text-status-danger", + cyan: "text-syntax-property", + blue: "text-syntax-builtin", }[tone]; return ( @@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe } export function SessionOverview({ session, agents }: SessionOverviewProps) { + const { t } = useTranslation("sessions"); const [stats, setStats] = useState(null); - const refreshTimerRef = useRef | null>(null); const fetchingRef = useRef(false); // Tick clock every 30s so a still-active session's "duration" tile stays current. @@ -170,8 +173,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { return () => window.clearInterval(id); }, [session.status]); + // A refresh asked for while one is in flight is not dropped: it runs once + // when the current one settles (that is the throttle's trailing call). + const pendingRef = useRef(false); const fetchStats = async () => { - if (fetchingRef.current) return; + if (fetchingRef.current) { + pendingRef.current = true; + return; + } fetchingRef.current = true; try { const result = await api.sessions.stats(session.id); @@ -180,6 +189,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { // Non-fatal - overview just won't update this round. } finally { fetchingRef.current = false; + if (pendingRef.current) { + pendingRef.current = false; + void fetchStats(); + } } }; @@ -189,47 +202,29 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.id]); - // Live refresh on websocket events (debounced). - useEffect(() => { - const unsubscribe = eventBus.subscribe((msg) => { - if (isRemoteDataRefreshMessage(msg)) { - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => { - refreshTimerRef.current = null; - fetchStats(); - }, REFRESH_DEBOUNCE_MS); - return; - } - const isRelevant = - msg.type === "new_event" || - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "session_updated"; - if (!isRelevant) return; + // Live refresh on this session's websocket events, throttled (a steady + // stream still refreshes) and again after a reconnect. + useLiveRefresh(() => void fetchStats(), { + throttleMs: REFRESH_THROTTLE_MS, + match: (msg) => { + if (isRemoteDataRefreshMessage(msg)) return true; + if ( + msg.type !== "new_event" && + msg.type !== "agent_created" && + msg.type !== "agent_updated" && + msg.type !== "session_updated" + ) + return false; const data = msg.data as { session_id?: string; id?: string }; // Match either by session_id (events) or by id (session_updated) - const matchesSession = data.session_id === session.id || data.id === session.id; - if (!matchesSession) return; - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => { - refreshTimerRef.current = null; - fetchStats(); - }, REFRESH_DEBOUNCE_MS); - }); - return () => { - unsubscribe(); - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session.id]); + return data.session_id === session.id || data.id === session.id; + }, + }); - // Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name). - // We approximate "tool calls" as half of that (each call produces a Pre + Post event). - const toolCallCount = useMemo(() => { - if (!stats) return 0; - const total = stats.tools_used.reduce((s, t) => s + t.count, 0); - return Math.round(total / 2); - }, [stats]); + // Exact totals from the server (`tools_used` is only the top 15). An older + // server lacks them: fall back to the visible list's count. + const toolCallCount = stats?.tool_call_count ?? 0; + const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0; const maxToolCount = useMemo(() => { if (!stats) return 0; @@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { // Avg event rate (events / minute) const eventRate = useMemo(() => { if (!stats || !session.started_at) return null; - const start = new Date(session.started_at).getTime(); + const start = parseDate(session.started_at).getTime(); const end = session.ended_at - ? new Date(session.ended_at).getTime() + ? parseDate(session.ended_at).getTime() : stats.last_event_at - ? new Date(stats.last_event_at).getTime() + ? parseDate(stats.last_event_at).getTime() : now; const minutes = Math.max(1, (end - start) / 60_000); return stats.total_events / minutes; @@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { - {activeAgent.name || "Agent"} + {activeAgent.name || t("overview.agent")} {activeAgent.current_tool && ( - running + {t("overview.running")} {activeAgent.current_tool} )} @@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { {/* Stat tiles */}
0 - ? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min` + ? t("overview.perMin", { + rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate), + }) : undefined } icon={} /> 0 ? `${stats.tools_used.length} unique` : undefined} + hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined} icon={} tone="violet" /> 0 ? `+${stats.agents.main} main` : undefined} + hint={ + stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined + } icon={} tone="cyan" /> } tone="blue" /> } tone={stats.error_count > 0 ? "rose" : "default"} /> } tone={session.status === "active" ? "emerald" : "default"} /> @@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {

- - Top tools + + {t("overview.topTools")}

- {stats.tools_used.length} total + {t("overview.distinctTotal", { count: distinctTools })}
{stats.tools_used.length === 0 ? ( -
No tool calls yet.
+
+ {t("overview.noToolCalls")} +
) : (
{stats.tools_used.slice(0, 8).map((t) => ( @@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { if (stats.agents.compaction > 0) { rows.push({ key: "__compaction__", - label: "Context Compaction", + label: t("overview.contextCompaction"), count: stats.agents.compaction, isCompaction: true, }); @@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { <>

- - Subagents + + {t("overview.subagents")}

- {totalRuns} runs + + {t("overview.runs", { count: totalRuns })} +
{rows.length === 0 ? (
- No subagents in this session. + {t("overview.noSubagents")}
) : (
{rows.slice(0, 8).map((r) => { const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0; - const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60"; + const barClass = r.isCompaction + ? "bg-status-warning/60" + : "bg-syntax-property/60"; return (

- Token flow + {t("overview.tokenFlow")}

- {fmt(totalTokens)} total + + {t("overview.total", { value: fmt(totalTokens) })} +
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {

- Event mix + {t("overview.eventMix")}

{stats.events_by_type.slice(0, 12).map((e) => ( @@ -502,34 +511,35 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) { } function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) { + const { t } = useTranslation(); const segments = [ { key: "cache_read", - label: "Cache read", + label: t("common:token.cacheRead"), value: tokens.cache_read_tokens, - cls: "bg-sky-500", - text: "text-sky-700 dark:text-sky-300", + cls: "bg-syntax-builtin", + text: "text-syntax-builtin", }, { key: "cache_write", - label: "Cache write", + label: t("common:token.cacheWrite"), value: tokens.cache_write_tokens, - cls: "bg-violet-500", - text: "text-violet-700 dark:text-violet-300", + cls: "bg-syntax-keyword", + text: "text-syntax-keyword", }, { key: "input", - label: "Input", + label: t("common:token.input"), value: tokens.input_tokens, cls: "bg-status-success", text: "text-status-success", }, { key: "output", - label: "Output", + label: t("common:token.output"), value: tokens.output_tokens, - cls: "bg-orange-500", - text: "text-orange-700 dark:text-orange-300", + cls: "bg-syntax-number", + text: "text-syntax-number", }, ]; diff --git a/client/src/components/SessionRowMenu.tsx b/client/src/components/SessionRowMenu.tsx index 6b2bf835..fb660109 100644 --- a/client/src/components/SessionRowMenu.tsx +++ b/client/src/components/SessionRowMenu.tsx @@ -11,9 +11,26 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu"; +import { copyText } from "../lib/clipboard"; export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null }; +/** + * Where "Open in workspace" goes for a session: its live pane when a dashboard + * run holds it (`&run=`), else its workspace's replay (`&session=`); null when + * no workspace owns it. + */ +export function workspaceHref( + session: SessionRowRef, + runs: ReadonlyArray<{ id: string; laneId: number | null; sessionId: string | null }> = [] +): string | null { + const run = runs.find((r) => r.sessionId === session.id && r.laneId != null); + if (run) return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`; + if (session.lane_id != null) + return `/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`; + return null; +} + export function SessionRowMenu({ session, x, @@ -26,7 +43,7 @@ export function SessionRowMenu({ onClose(); fn(); }; - const copy = (text: string) => pick(() => void navigator.clipboard.writeText(text)); + const copy = (text: string) => pick(() => void copyText(text)); return ( @@ -34,11 +51,7 @@ export function SessionRowMenu({ {t("menu.details")} {session.lane_id != null && ( - - navigate(`/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`) - )} - > + navigate(workspaceHref(session)!))}> {t("menu.inWorkspace")} )} diff --git a/client/src/components/SplashScreen.tsx b/client/src/components/SplashScreen.tsx index 4666e7a6..06049720 100644 --- a/client/src/components/SplashScreen.tsx +++ b/client/src/components/SplashScreen.tsx @@ -68,7 +68,10 @@ export function SplashScreen() { const { t } = useTranslation("splash"); // Show at most once per tab session. Read synchronously so we never flash an // empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.). + // Never on a deep link (a shared /sessions/, a notification's + // /workspace?…): the user came for that page, not the welcome. const [mounted, setMounted] = useState(() => { + if (window.location.pathname !== "/") return false; try { return !sessionStorage.getItem(SESSION_KEY); } catch { @@ -109,7 +112,11 @@ export function SplashScreen() { /* sessionStorage may be unavailable (private mode) - show anyway */ } exitTimer.current = window.setTimeout(beginExit, HOLD_MS); + // Any key dismisses it (not only a click), so a keyboard user is never stuck. + const onKey = () => beginExit(); + window.addEventListener("keydown", onKey); return () => { + window.removeEventListener("keydown", onKey); if (exitTimer.current) window.clearTimeout(exitTimer.current); if (doneTimer.current) window.clearTimeout(doneTimer.current); }; diff --git a/client/src/components/StatCard.tsx b/client/src/components/StatCard.tsx index 1ca81579..d45f1290 100644 --- a/client/src/components/StatCard.tsx +++ b/client/src/components/StatCard.tsx @@ -54,6 +54,7 @@ * * ----------------------------------------------------------------------------- */ +import { Link } from "react-router-dom"; import type { LucideIcon } from "lucide-react"; import { Tip } from "./Tip"; import { StatValueSkeleton } from "./Skeleton"; @@ -74,6 +75,8 @@ interface StatCardProps { raw?: string; /** When true, render skeletons in place of value/trend. */ loading?: boolean; + /** Drill-in: the whole card links here (e.g. the list the number counts). */ + to?: string; } /** @@ -88,9 +91,10 @@ export function StatCard({ accentColor = "text-accent", raw, loading = false, + to, }: StatCardProps) { - return ( -
+ const body = ( + <>
{label} @@ -109,6 +113,16 @@ export function StatCard({ {trend} )}
-
+ + ); + return to ? ( + + {body} + + ) : ( +
{body}
); } diff --git a/client/src/components/StatusBadge.tsx b/client/src/components/StatusBadge.tsx index 579eea12..5c3259cd 100644 --- a/client/src/components/StatusBadge.tsx +++ b/client/src/components/StatusBadge.tsx @@ -79,8 +79,8 @@ export const REASON_ICONS: Record = { /** * The "why" chip nested inside a Waiting badge: a small rounded pill with the * reason's icon and short label. Urgent reasons (permission prompts, - * interruptions) get a hotter amber fill than the calm idle-between-turns - * ones so a scan of a list surfaces the rows that actually block on the human. + * interruptions) get the amber warning fill; the calm idle-between-turns + * ones stay neutral, so a scan of a list surfaces the rows that actually block on the human. */ function ReasonChip({ reason }: { reason: AwaitingReason }) { const { t } = useTranslation(); @@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) { className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${ cfg.urgent ? "bg-status-warning/15 border-status-warning/25 text-status-warning" - : "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90" + : "bg-surface-3 border-border text-fg-secondary" }`} >
)} @@ -440,30 +452,26 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio {loadingHistory && (
- Loading history... + {t("conversation.loadingHistory")}
)} {/* Scroll-up for history hint */} {hasMore && !loadingHistory && !loading && (
- ↑ Scroll up for older messages + ↑ {t("conversation.scrollForOlder")}
)} {loading ? (
- Loading conversation... + {t("conversation.loading")}
) : messages.length === 0 ? (
-

No conversation records found.

+

{t("conversation.empty")}

- This session's metadata was imported, but its transcript file is no longer on disk. - Claude Code automatically deletes inactive session transcripts after a retention - period (cleanupPeriodDays, default 30 - days), so older conversations may already be gone. Sessions imported from now on are - snapshotted and kept even after Claude Code prunes the originals. + {t("conversation.emptyBody")}

) : ( @@ -478,10 +486,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio scrollToBottom(); setShowNewMsg(false); }} - className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-violet-600 hover:bg-violet-500 text-white text-xs font-medium px-3 py-1.5 rounded-full shadow-lg transition-colors z-10" + className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-accent-solid hover:bg-accent-solid-hover text-white text-xs font-medium px-3 py-1.5 rounded-full shadow-lg transition-colors z-10" > - New messages + {t("conversation.newMessages")} )}
diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 57c0bffc..c90eb945 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -53,7 +53,9 @@ * * ----------------------------------------------------------------------------- */ -import { useState, useMemo } from "react"; +import { memo, useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import i18n from "../../i18n"; import { ChevronDown, ChevronRight, @@ -76,45 +78,35 @@ import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../ * and accent so attribution is unambiguous. */ const SENDER_STYLES: Record< TranscriptSender, - { label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string } + { icon: typeof User; avatarRing: string; accentBar: string; headerText: string } > = { user: { - label: "User", icon: User, - avatarRing: - "bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30", - accentBar: "before:bg-blue-600/40", - headerText: "text-blue-700 dark:text-blue-300", + avatarRing: "bg-syntax-builtin/15 text-syntax-builtin ring-1 ring-syntax-builtin/30", + accentBar: "before:bg-syntax-builtin/40", + headerText: "text-syntax-builtin", }, assistant: { - label: "Assistant", icon: Bot, - avatarRing: - "bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30", - accentBar: "before:bg-violet-500/40", - headerText: "text-violet-800 dark:text-violet-200", + avatarRing: "bg-syntax-keyword/15 text-syntax-keyword ring-1 ring-syntax-keyword/30", + accentBar: "before:bg-syntax-keyword/40", + headerText: "text-syntax-keyword", }, orchestrator: { - label: "Main agent", icon: Workflow, - avatarRing: - "bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30", - accentBar: "before:bg-teal-500/40", - headerText: "text-teal-800 dark:text-teal-200", + avatarRing: "bg-syntax-string/15 text-syntax-string ring-1 ring-syntax-string/30", + accentBar: "before:bg-syntax-string/40", + headerText: "text-syntax-string", }, system: { - label: "System", icon: Cog, - avatarRing: - "bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30", + avatarRing: "bg-surface-4 text-fg-secondary ring-1 ring-border-light/30", accentBar: "before:bg-surface-4/40", headerText: "text-fg-secondary", }, tool: { - label: "Tool", icon: Terminal, - avatarRing: - "bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30", + avatarRing: "bg-status-warning/15 text-status-warning ring-1 ring-status-warning/30", accentBar: "before:bg-status-warning/40", headerText: "text-status-warning", }, @@ -242,7 +234,7 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode { key={key} text={seg.text} icon={} - title="System reminder" + title={i18n.t("sessions:conversation.systemReminder")} borderClass="border-status-warning/20" bgClass="bg-status-warning/5" textClass="text-status-warning/80" @@ -253,13 +245,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode { - } - title="Persisted output" - borderClass="border-violet-500/20" - bgClass="bg-violet-500/5" - textClass="text-violet-700/80 dark:text-violet-300/80" + icon={} + title={i18n.t("sessions:conversation.persistedOutput")} + borderClass="border-syntax-keyword/20" + bgClass="bg-syntax-keyword/5" + textClass="text-syntax-keyword" /> ); case "text": { @@ -296,6 +286,7 @@ function CollapsibleBlock({
- {isExpanded && ( -
- -
- )} -
- ); - } - - if (block.type === "tool_use") { - const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null; - return ; - } - - // tool_result blocks rendered inside ToolCallBlock, skip standalone - return null; - })} -
-
- ); + return ; })}
); } + +/** One user/assistant row. Memoized: a live tail append re-renders only the + * new rows; its thinking-block expansion is its own state, kept by key. */ +const MessageRow = memo(function MessageRow({ + msg, + toolResultMap, +}: { + msg: TranscriptMessage; + toolResultMap: ReturnType; +}) { + const { t } = useTranslation("sessions"); + const [expandedThinking, setExpandedThinking] = useState>(() => new Set()); + const isAssistant = msg.type === "assistant"; + // The true sender (classified server-side) drives the label + styling. + // Falls back to the coarse type for older payloads without `sender`. + const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user"); + const style = SENDER_STYLES[sender] ?? SENDER_STYLES.user; + const SenderIcon = style.icon; + + return ( +
+ {/* Avatar */} +
+ +
+ + {/* Message body */} +
+ {/* Header line */} +
+ + {t(`conversation.sender.${sender}`)} + + {msg.model && ( + + {formatModelName(msg.model)} + + )} + {msg.usage && ( + + ↓ {fmt(msg.usage.input_tokens)} + · + ↑ {fmt(msg.usage.output_tokens)} + + )} + {msg.timestamp && ( + + {formatLocalTime(msg.timestamp)} + + )} +
+ + {/* Content blocks */} + {msg.content.map((block, bIdx) => { + if (block.type === "text" && block.text) { + // Detect task notifications, collapsed by default + if (isTaskNotification(block.text)) { + return ( + + } + title={t("conversation.taskNotification")} + borderClass="border-syntax-property/20" + bgClass="bg-syntax-property/5" + textClass="text-syntax-property" + /> + ); + } + + // Detect skill content, collapsed by default + if (isSkillContent(block.text)) { + const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/); + const skillPath = pathMatch ? pathMatch[1]! : "Skill"; + return ( + } + title={skillPath} + borderClass="border-syntax-builtin/20" + bgClass="bg-syntax-builtin/5" + textClass="text-syntax-builtin" + /> + ); + } + + // Mixed TUI markup: caveat / command / stdout / stderr / system-reminder + // can appear inline (sometimes interleaved with prose). Parse the text + // into segments and render each with the appropriate visual treatment. + if (hasTuiTags(block.text)) { + const segments = parseTuiSegments(block.text); + return ( +
+ {segments.map((s, sIdx) => renderSegment(s, sIdx))} +
+ ); + } + + return ( +
+ +
+ ); + } + + if (block.type === "thinking" && block.text) { + const thinkKey = bIdx; + const isExpanded = expandedThinking.has(thinkKey); + return ( +
+ + {isExpanded && ( +
+ +
+ )} +
+ ); + } + + if (block.type === "tool_use") { + const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null; + return ; + } + + // tool_result blocks rendered inside ToolCallBlock, skip standalone + return null; + })} +
+
+ ); +}); diff --git a/client/src/components/conversation/ToolCallBlock.tsx b/client/src/components/conversation/ToolCallBlock.tsx index 32edd362..fd4dc5c0 100644 --- a/client/src/components/conversation/ToolCallBlock.tsx +++ b/client/src/components/conversation/ToolCallBlock.tsx @@ -51,6 +51,7 @@ * * ----------------------------------------------------------------------------- */ +import i18n from "../../i18n"; import { useState } from "react"; import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react"; import type { TranscriptContent } from "../../lib/types"; @@ -107,6 +108,9 @@ function buildSummary(toolUse: TranscriptContent): string | null { return null; } +/** A translated label for the tool panes (plain functions, so i18n directly). */ +const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`); + /** Render the input pane with tool-aware formatting. */ function renderInput(toolUse: TranscriptContent) { const input = toolUse.input; @@ -118,7 +122,7 @@ function renderInput(toolUse: TranscriptContent) { ); } @@ -130,7 +134,7 @@ function renderInput(toolUse: TranscriptContent) { if (tool === "bash" && typeof obj.command === "string") { return (
- + {typeof obj.description === "string" && (

{obj.description}

)} @@ -145,7 +149,7 @@ function renderInput(toolUse: TranscriptContent) { code={obj.content} lang={langFromPath(obj.file_path)} filename={obj.file_path} - label="New file" + label={tt("newFile")} /> ); } @@ -156,19 +160,19 @@ function renderInput(toolUse: TranscriptContent) { return (
- + {obj.file_path} {obj.replace_all === true && ( - replace all + {tt("replaceAll")} )}
{typeof obj.old_string === "string" && ( - + )} {typeof obj.new_string === "string" && ( - + )}
); @@ -178,7 +182,7 @@ function renderInput(toolUse: TranscriptContent) { if (tool === "read" && typeof obj.file_path === "string") { return (
- + {obj.file_path} {(typeof obj.offset === "number" || typeof obj.limit === "number") && ( @@ -196,16 +200,16 @@ function renderInput(toolUse: TranscriptContent) {
- Pattern + {tt("pattern")} - + {obj.pattern}
{typeof obj.path === "string" && (
- Path + {tt("path")} {obj.path}
@@ -213,7 +217,7 @@ function renderInput(toolUse: TranscriptContent) { {typeof obj.glob === "string" && (
- Glob + {tt("glob")} {obj.glob}
@@ -223,17 +227,18 @@ function renderInput(toolUse: TranscriptContent) { } // Default: pretty JSON - return ; + return ; } /** Render the result pane: detect diff/json/text. */ function renderResult(toolResult: TranscriptContent, toolName: string) { const text = toolResult.output ?? ""; - if (text.length === 0) return
(empty)
; + if (text.length === 0) + return
({tt("empty")})
; const isError = !!toolResult.is_error; const tool = toolName.toLowerCase(); - const label = isError ? "Error" : "Output"; + const label = isError ? tt("error") : tt("output"); // Heuristics for language let lang = "plain"; @@ -273,6 +278,7 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) { {/* Collapsed/expanded toggle */} +
+ + {t("drillIn.openSession")} + + {session.lane_id != null && ( + + {t("drillIn.openInWorkspace")} + + )} + +
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [sessions, setSessions] = useState([]); - const [allSessions, setAllSessions] = useState([]); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); - const [allLoaded, setAllLoaded] = useState(false); const containerRef = useRef(null); const inputRef = useRef(null); + // Typing fires overlapping requests; only the newest may write the list. + const seqRef = useRef(0); - const fetchPage = useCallback((pageOffset: number, replace: boolean) => { + // The search runs on the server (`q` matches id, name and folder), one page + // at a time — never a download of every session to filter here. + const fetchPage = useCallback((q: string, pageOffset: number, replace: boolean) => { + const seq = ++seqRef.current; setLoading(true); api.sessions - .list({ limit: PAGE_SIZE, offset: pageOffset }) + .list({ q: q || undefined, limit: PAGE_SIZE, offset: pageOffset }) .then(({ sessions: page }) => { + if (seq !== seqRef.current) return; setSessions((prev) => (replace ? page : [...prev, ...page])); setHasMore(page.length === PAGE_SIZE); setOffset(pageOffset + page.length); }) .catch(() => {}) - .finally(() => setLoading(false)); + .finally(() => { + if (seq === seqRef.current) setLoading(false); + }); }, []); - // Load ALL sessions for search (once, lazily) - const loadAllSessions = useCallback(() => { - if (allLoaded) return; - setAllLoaded(true); - // Fetch large batch for search - api.sessions - .list({ limit: 5000, offset: 0 }) - .then(({ sessions: all }) => setAllSessions(all)) - .catch(() => {}); - }, [allLoaded]); - - // Load first page when dropdown opens + // (Re)load the first page while open, debounced on the search text. + const query = search.trim(); useEffect(() => { - if (open && sessions.length === 0) { - fetchPage(0, true); - } - }, [open, sessions.length, fetchPage]); - - // When user starts typing, load all sessions for search - useEffect(() => { - if (search.trim().length > 0) { - loadAllSessions(); - } - }, [search, loadAllSessions]); + if (!open) return; + const timer = setTimeout(() => fetchPage(query, 0, true), query ? 250 : 0); + return () => clearTimeout(timer); + }, [open, query, fetchPage]); // Close on outside click useEffect(() => { @@ -536,13 +533,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) { return () => document.removeEventListener("mousedown", handleClick); }, [open]); - // When searching, filter across ALL sessions; otherwise show paginated - const filtered = search.trim() - ? (allSessions.length > 0 ? allSessions : sessions).filter((s) => { - const q = search.toLowerCase(); - return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q); - }) - : sessions; + const filtered = sessions; function handleSelect(id: string) { setOpen(false); @@ -551,7 +542,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) { } function handleLoadMore() { - fetchPage(offset, false); + fetchPage(query, offset, false); } return ( @@ -560,7 +551,8 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
{ setOpen(true); @@ -573,6 +565,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) { type="text" value={search} placeholder={t("drillIn.searchPlaceholder")} + aria-label={t("drillIn.searchPlaceholder")} className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0" onFocus={() => setOpen(true)} onChange={(e) => { @@ -648,8 +641,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
)} - {/* Load more - only show when not filtering client-side */} - {!search.trim() && hasMore && ( + {hasMore && (
@@ -598,7 +598,7 @@ export function WorkflowRunsPanel({
{t("runs.resultLabel")}
-
+                                
                                   {fullResult}
                                 
diff --git a/client/src/hooks/__tests__/useLiveRefresh.test.ts b/client/src/hooks/__tests__/useLiveRefresh.test.ts new file mode 100644 index 00000000..f1233bd2 --- /dev/null +++ b/client/src/hooks/__tests__/useLiveRefresh.test.ts @@ -0,0 +1,50 @@ +/** + * @file useLiveRefresh.test.ts + * @description Tests the shared live-refresh hook: matched WebSocket messages + * reload through a throttle, others are ignored, a reconnect reloads, and a + * disabled hook does nothing. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useLiveRefresh } from "../useLiveRefresh"; +import { eventBus } from "../../lib/eventBus"; +import type { WSMessage } from "../../lib/types"; + +const msg = (type: WSMessage["type"]) => ({ type, data: {} }) as unknown as WSMessage; + +afterEach(() => { + vi.useRealTimers(); + eventBus.setConnected(false); +}); + +describe("useLiveRefresh", () => { + it("throttles matched messages and ignores the rest", () => { + vi.useFakeTimers(); + const load = vi.fn(); + renderHook(() => useLiveRefresh(load, { match: (m) => m.type === "new_event" })); + eventBus.publish(msg("agent_updated")); + expect(load).not.toHaveBeenCalled(); + for (let i = 0; i < 10; i++) eventBus.publish(msg("new_event")); + expect(load).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(3000); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("reloads after the socket reconnects", () => { + const load = vi.fn(); + eventBus.setConnected(true); + renderHook(() => useLiveRefresh(load)); + eventBus.setConnected(false); + eventBus.setConnected(true); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("does nothing while disabled", () => { + const load = vi.fn(); + renderHook(() => useLiveRefresh(load, { enabled: false, match: () => true })); + eventBus.publish(msg("new_event")); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/useLiveRefresh.ts b/client/src/hooks/useLiveRefresh.ts new file mode 100644 index 00000000..41fbb56b --- /dev/null +++ b/client/src/hooks/useLiveRefresh.ts @@ -0,0 +1,71 @@ +/** + * @file useLiveRefresh.ts + * @description Keeps a page's data fresh without hammering the server: re-runs + * `load` on matching WebSocket messages through a leading+trailing throttle (a + * steady event stream can never freeze or flood the page), on a poll that skips + * hidden tabs, when the tab becomes visible again, and after the socket + * reconnects (messages sent while it was down are lost). The caller still does + * its own initial load. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useRef } from "react"; +import { eventBus } from "../lib/eventBus"; +import { throttle } from "../lib/throttle"; +import type { WSMessage } from "../lib/types"; + +export interface LiveRefreshOptions { + /** Which WebSocket messages warrant a reload; none when omitted. */ + match?: (msg: WSMessage) => boolean; + /** Poll period while the tab is visible; no poll when omitted. */ + intervalMs?: number; + /** Minimum gap between event-driven reloads (default 3 s). */ + throttleMs?: number; + /** Off = no subscription, no poll (e.g. an inactive tab). Default true. */ + enabled?: boolean; +} + +export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}): void { + const { intervalMs, throttleMs = 3000, enabled = true } = opts; + const loadRef = useRef(load); + const matchRef = useRef(opts.match); + loadRef.current = load; + matchRef.current = opts.match; + + useEffect(() => { + if (!enabled) return; + const live = throttle(() => loadRef.current(), throttleMs); + const visible = () => document.visibilityState === "visible"; + const offMsg = eventBus.subscribe((msg) => { + if (matchRef.current?.(msg)) live(); + }); + let wasDown = !eventBus.connected; + // (Guarded: test doubles of the bus often stub `subscribe` only.) + const offConn = + typeof eventBus.onConnection === "function" + ? eventBus.onConnection((up) => { + if (!up) wasDown = true; + else if (wasDown) { + wasDown = false; + live(); + } + }) + : () => undefined; + const onVis = () => { + if (visible()) live(); + }; + document.addEventListener("visibilitychange", onVis); + const timer = intervalMs + ? setInterval(() => { + if (visible()) loadRef.current(); + }, intervalMs) + : undefined; + return () => { + live.cancel(); + offMsg(); + offConn(); + document.removeEventListener("visibilitychange", onVis); + clearInterval(timer); + }; + }, [enabled, intervalMs, throttleMs]); +} diff --git a/client/src/i18n/locales/en/activity.json b/client/src/i18n/locales/en/activity.json index 45abba78..c65f46e7 100644 --- a/client/src/i18n/locales/en/activity.json +++ b/client/src/i18n/locales/en/activity.json @@ -1,10 +1,17 @@ { "title": "Activity Feed", "subtitle": "Real-time stream of all agent events", - "paused": "(paused - {{count}} buffered)", + "paused": "(paused — {{count}} new)", "resume": "Resume", "pause": "Pause", "noActivity": "No activity yet", "noActivityDesc": "Events will stream here in real-time as Claude Code agents work.", - "viewSession": "Session" + "viewSession": "Session", + "exportCsv": "CSV", + "exportJson": "JSON", + "failedLoad": "Failed to load events", + "newEvents_one": "{{count}} new event — back to the latest", + "newEvents_other": "{{count}} new events — back to the latest", + "firstPage": "First page", + "lastPage": "Last page" } diff --git a/client/src/i18n/locales/en/analytics.json b/client/src/i18n/locales/en/analytics.json index e3447798..132bba79 100644 --- a/client/src/i18n/locales/en/analytics.json +++ b/client/src/i18n/locales/en/analytics.json @@ -50,5 +50,8 @@ "noSessionTrendData": "No session trend data yet.", "last7Days": "Last 7 days", "less": "Less", - "more": "More" + "more": "More", + "tokensExCacheReads": "Tokens (excl. cache reads)", + "cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate", + "avgPerWeekday30d": "Average per weekday, last 30 days" } diff --git a/client/src/i18n/locales/en/dashboard.json b/client/src/i18n/locales/en/dashboard.json index 531609e7..f8cf6d4f 100644 --- a/client/src/i18n/locales/en/dashboard.json +++ b/client/src/i18n/locales/en/dashboard.json @@ -18,5 +18,94 @@ "failedConnect": "Failed to connect to server", "failedLoad": "Failed to load data", "activeTrend": " active", - "totalTrend": " in active sessions" + "totalTrend": " in active sessions", + "tabs": { + "label": "Dashboard views", + "analytics": "Analytics", + "monitor": "Monitor", + "health": "Health" + }, + "collapseSubagents": "Collapse subagents", + "expandSubagents": "Expand subagents", + "needsYou": { + "title": "Needs you", + "since": "waiting {{ago}}" + }, + "health": { + "runtime": "Runtime", + "cores_one": "{{count}} core", + "cores_other": "{{count}} cores", + "uptime": "Uptime", + "cpu": "CPU (1/5/15m)", + "nodeRss": "Node RSS", + "hostMemory": "Host Memory", + "hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}", + "heap": "V8 Heap", + "heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}", + "storage": "Storage", + "writeVelocityTip": "Write velocity (events):\n5 min: {{m5}}\n15 min: {{m15}}\n1 hr: {{h1}}", + "database": "Database", + "recordsTip": "Record distribution\n\nSessions: {{sessions}} ({{sessPct}}%)\nAgents: {{agents}} ({{agentPct}}%)\nEvents: {{events}} ({{eventPct}}%)\n\nTotal: {{total}} records", + "total": "total", + "sessions": "Sessions", + "agents": "Agents", + "events": "Events", + "score": "Health Score", + "formula": "Formula", + "formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}", + "scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%", + "cache": "Cache", + "cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}", + "errors": "Errors", + "errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical", + "compact": "Compact", + "compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.", + "saved": "Saved", + "savedTip": "Tokens recovered: {{tokens}}\nFreed by compaction.", + "tokenUsage": "Token Usage", + "totalSuffix": "{{value}} total", + "modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%", + "noModelData": "No model data", + "concurrency": "Concurrency", + "intervals_one": "{{count}} interval", + "intervals_other": "{{count}} intervals", + "concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}", + "noConcurrencyData": "No concurrency data", + "peak": "Peak", + "peakTip": "Peak: {{count}} sessions running simultaneously.", + "active": "Active", + "activeTip": "{{active}} of {{total}} intervals have active sessions.", + "avg": "Avg", + "avgTip": "Average concurrency across all intervals.", + "toolUsage": "Tool Usage", + "top": "top {{count}}", + "invocationsTip_one": "{{tool}}: {{formatted}} invocation", + "invocationsTip_other": "{{tool}}: {{formatted}} invocations", + "noToolData": "No tool data yet", + "subagentEffectiveness": "Subagent Effectiveness", + "defaultType": "default", + "effectivenessTip": "{{type}}\nSuccess: {{pct}}%\nTotal: {{total}} · OK: {{ok}} · Errors: {{errors}}", + "noSubagentData": "No subagent data yet", + "integration": "Integration", + "hooksActive": "Active", + "hookTip": "Path: {{path}}\nStatus: {{status}}", + "connected": "Connected", + "disconnected": "Disconnected", + "noHooks": "No project hooks registered", + "wsTip": "WebSocket connections: {{count}}\nProtocol: RFC 6455", + "wsActive": "WebSocket active", + "connections_one": "{{count}} connection", + "connections_other": "{{count}} connections", + "platform": "Platform", + "journalMode": "Journal mode", + "synchronous": "Synchronous", + "autoVacuum": "Auto-vacuum", + "foreignKeys": "Foreign keys", + "busyTimeout": "Busy timeout" + }, + "errorBoundary": { + "title": "Something went wrong", + "body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.", + "reload": "Reload" + } } diff --git a/client/src/i18n/locales/en/sessions.json b/client/src/i18n/locales/en/sessions.json index 36006c42..9d8fbef2 100644 --- a/client/src/i18n/locales/en/sessions.json +++ b/client/src/i18n/locales/en/sessions.json @@ -37,7 +37,14 @@ "noEvents": "No events recorded.", "conversation": "Conversation", "timeline": "Timeline ({{shown}}/{{total}})", - "transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked." + "transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked.", + "back": "Back", + "collapseSubagents": "Collapse subagents", + "expandSubagents": "Expand subagents", + "dashboardRun": { + "title": "A dashboard pane is running this session", + "body": "Open its workspace pane to send follow-ups, watch the output or stop it." + } }, "remoteSourceBadgeTitle": "Collected from a remote machine", "toolBadgeTitle": "Recorded by {{tool}}", @@ -46,5 +53,108 @@ "inWorkspace": "Open in its workspace", "copyId": "Copy session ID", "copyFolder": "Copy folder path" + }, + "exportCsv": "CSV", + "exportJson": "JSON", + "filterDirectory": "Filter by directory", + "allDirectories": "All directories", + "sortLabel": "Sort sessions", + "sortTimeDesc": "Sort by time (newest)", + "sortTimeAsc": "Sort by time (oldest)", + "sortDurationDesc": "Sort by duration (longest)", + "sortDurationAsc": "Sort by duration (shortest)", + "sortPriceDesc": "Sort by price (highest)", + "sortPriceAsc": "Sort by price (lowest)", + "descending": "Descending", + "ascending": "Ascending", + "filterTool": "Filter by AI tool", + "allTools": "All AI tools", + "filterWorkspace": "Filter by workspace", + "allWorkspaces": "All workspaces", + "dateFrom": "From", + "dateTo": "To", + "clearFilters": "Clear filters", + "failedList": "Failed to load sessions", + "tableRegion": "Sessions table", + "openInWorkspace": "Open in workspace", + "openInWorkspaceTitle": "A dashboard pane is running this session — open it", + "datePicker": { + "placeholder": "Select date & time", + "clear": "Clear date", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "time": "Time" + }, + "conversation": { + "failedLoad": "Failed to load transcript", + "transcript": "Transcript", + "messages_one": "{{count}} message", + "messages_other": "{{count}} messages", + "refresh": "Refresh conversation", + "loadingHistory": "Loading history…", + "scrollForOlder": "Scroll up for older messages", + "loading": "Loading conversation…", + "empty": "No conversation records found.", + "emptyBody": "This session's metadata was imported, but its transcript file is no longer on disk. Claude Code automatically deletes inactive session transcripts after a retention period (cleanupPeriodDays, default 30 days), so older conversations may already be gone. Sessions imported from now on are snapshotted and kept even after Claude Code prunes the originals.", + "newMessages": "New messages", + "systemReminder": "System reminder", + "persistedOutput": "Persisted output", + "taskNotification": "Task notification", + "thinking": "Thinking", + "chars_one": "{{formatted}} char", + "chars_other": "{{formatted}} chars", + "sender": { + "user": "User", + "assistant": "Assistant", + "orchestrator": "Main agent", + "system": "System", + "tool": "Tool" + }, + "lines_one": "{{count}} line", + "lines_other": "{{count}} lines", + "copyCode": "Copy code", + "copy": "Copy", + "tool": { + "inputTruncated": "Input (truncated)", + "command": "Command", + "newFile": "New file", + "replaceAll": "replace all", + "removed": "Removed", + "added": "Added", + "pattern": "Pattern", + "path": "Path", + "glob": "Glob", + "input": "Input", + "empty": "empty", + "error": "Error", + "output": "Output", + "statusError": "error", + "statusOk": "ok", + "statusPending": "pending" + } + }, + "overview": { + "agent": "Agent", + "running": "running", + "events": "Events", + "perMin": "{{rate}}/min", + "toolCalls": "Tool calls", + "unique": "{{count}} unique", + "subagents": "Subagents", + "plusMain_one": "+{{count}} main", + "plusMain_other": "+{{count}} main", + "compactions": "Compactions", + "errors": "Errors", + "duration": "Duration", + "topTools": "Top tools", + "distinctTotal": "{{count}} total", + "noToolCalls": "No tool calls yet.", + "contextCompaction": "Context Compaction", + "runs_one": "{{count}} run", + "runs_other": "{{count}} runs", + "noSubagents": "No subagents in this session.", + "tokenFlow": "Token flow", + "total": "{{value}} total", + "eventMix": "Event mix" } } diff --git a/client/src/i18n/locales/en/workflows.json b/client/src/i18n/locales/en/workflows.json index 13ee7eee..73ff62ef 100644 --- a/client/src/i18n/locales/en/workflows.json +++ b/client/src/i18n/locales/en/workflows.json @@ -204,6 +204,15 @@ "outcomeAbandoned": "Sessions that stopped without a clean Stop event and without explicit errors - usually closed mid-flight.", "outcomeFallback": "The terminal status of sessions in scope, computed from the last hook event observed for each session." } + }, + "legendItems": { + "sessions": "Sessions", + "mainAgent": "Main agent", + "subagentTypes": "Subagent types", + "compactions": "Compactions", + "completed": "Completed", + "error": "Error", + "abandoned": "Abandoned" } }, "drillIn": { @@ -230,7 +239,9 @@ "noEvents": "No events recorded for this session.", "showingOf": "Showing 100 of {{total}} events", "sessionInfo": " session(s) - active {{from}}%-{{to}}% of session", - "sortedByCompaction": " session(s) (sorted by compaction count)" + "sortedByCompaction": " session(s) (sorted by compaction count)", + "openSession": "Open session", + "openInWorkspace": "Open in workspace" }, "lanes": { "title": "Workspace Pipeline Pass Rates", @@ -316,5 +327,7 @@ "sessions_one": "{{count}} session", "sessions_other": "{{count}} sessions", "pluginBreakdown": "{{skills}} skill(s) used · {{skillCalls}} skill call(s) · {{mcpCalls}} MCP tool call(s)" - } + }, + "selectedNode": "Selected:", + "clearSelection": "Clear selection" } diff --git a/client/src/i18n/locales/vi/activity.json b/client/src/i18n/locales/vi/activity.json index 8cb35dde..c7dd4844 100644 --- a/client/src/i18n/locales/vi/activity.json +++ b/client/src/i18n/locales/vi/activity.json @@ -1,10 +1,17 @@ { "title": "Luồng hoạt động", "subtitle": "Luồng thời gian thực của mọi sự kiện Agent", - "paused": "(đã tạm dừng - {{count}} mục đệm)", + "paused": "(đã tạm dừng — {{count}} sự kiện mới)", "resume": "Tiếp tục", "pause": "Tạm dừng", "noActivity": "Chưa có hoạt động", "noActivityDesc": "Sự kiện sẽ được phát trực tiếp tại đây khi các Agent Claude Code làm việc.", - "viewSession": "Phiên" + "viewSession": "Phiên", + "exportCsv": "CSV", + "exportJson": "JSON", + "failedLoad": "Không thể tải sự kiện", + "newEvents_one": "{{count}} sự kiện mới — về mới nhất", + "newEvents_other": "{{count}} sự kiện mới — về mới nhất", + "firstPage": "Trang đầu", + "lastPage": "Trang cuối" } diff --git a/client/src/i18n/locales/vi/analytics.json b/client/src/i18n/locales/vi/analytics.json index 1dfdc98c..18061250 100644 --- a/client/src/i18n/locales/vi/analytics.json +++ b/client/src/i18n/locales/vi/analytics.json @@ -50,5 +50,8 @@ "noSessionTrendData": "Chưa có dữ liệu xu hướng phiên.", "last7Days": "7 ngày gần nhất", "less": "Ít", - "more": "Nhiều" + "more": "Nhiều", + "tokensExCacheReads": "Token (trừ đọc cache)", + "cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%", + "avgPerWeekday30d": "Trung bình theo thứ trong tuần, 30 ngày qua" } diff --git a/client/src/i18n/locales/vi/dashboard.json b/client/src/i18n/locales/vi/dashboard.json index 6ed33068..41111b43 100644 --- a/client/src/i18n/locales/vi/dashboard.json +++ b/client/src/i18n/locales/vi/dashboard.json @@ -18,5 +18,94 @@ "failedConnect": "Không thể kết nối máy chủ", "failedLoad": "Không thể tải dữ liệu", "activeTrend": " đang hoạt động", - "totalTrend": " trong phiên hoạt động" + "totalTrend": " trong phiên hoạt động", + "tabs": { + "label": "Các chế độ xem tổng quan", + "analytics": "Phân tích", + "monitor": "Giám sát", + "health": "Sức khỏe hệ thống" + }, + "collapseSubagents": "Thu gọn subagent", + "expandSubagents": "Mở rộng subagent", + "needsYou": { + "title": "Cần bạn xử lý", + "since": "đang chờ {{ago}}" + }, + "health": { + "runtime": "Môi trường chạy", + "cores_one": "{{count}} lõi", + "cores_other": "{{count}} lõi", + "uptime": "Thời gian chạy", + "cpu": "CPU (1/5/15 phút)", + "nodeRss": "Bộ nhớ Node (RSS)", + "hostMemory": "Bộ nhớ máy", + "hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}", + "heap": "Heap V8", + "heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}", + "storage": "Lưu trữ", + "writeVelocityTip": "Tốc độ ghi (sự kiện):\n5 phút: {{m5}}\n15 phút: {{m15}}\n1 giờ: {{h1}}", + "database": "Cơ sở dữ liệu", + "recordsTip": "Phân bố bản ghi\n\nPhiên: {{sessions}} ({{sessPct}}%)\nAgent: {{agents}} ({{agentPct}}%)\nSự kiện: {{events}} ({{eventPct}}%)\n\nTổng: {{total}} bản ghi", + "total": "tổng", + "sessions": "Phiên", + "agents": "Agent", + "events": "Sự kiện", + "score": "Điểm sức khỏe", + "formula": "Công thức", + "formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}", + "scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%", + "cache": "Cache", + "cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}", + "errors": "Lỗi", + "errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng", + "compact": "Nén", + "compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.", + "saved": "Tiết kiệm", + "savedTip": "Token thu hồi: {{tokens}}\nĐược giải phóng nhờ nén.", + "tokenUsage": "Sử dụng token", + "totalSuffix": "tổng {{value}}", + "modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%", + "noModelData": "Chưa có dữ liệu mô hình", + "concurrency": "Đồng thời", + "intervals_one": "{{count}} khoảng", + "intervals_other": "{{count}} khoảng", + "concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}", + "noConcurrencyData": "Chưa có dữ liệu đồng thời", + "peak": "Đỉnh", + "peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.", + "active": "Hoạt động", + "activeTip": "{{active}} trên {{total}} khoảng có phiên hoạt động.", + "avg": "TB", + "avgTip": "Mức đồng thời trung bình trên mọi khoảng.", + "toolUsage": "Sử dụng công cụ", + "top": "top {{count}}", + "invocationsTip_one": "{{tool}}: {{formatted}} lần gọi", + "invocationsTip_other": "{{tool}}: {{formatted}} lần gọi", + "noToolData": "Chưa có dữ liệu công cụ", + "subagentEffectiveness": "Hiệu quả subagent", + "defaultType": "mặc định", + "effectivenessTip": "{{type}}\nThành công: {{pct}}%\nTổng: {{total}} · OK: {{ok}} · Lỗi: {{errors}}", + "noSubagentData": "Chưa có dữ liệu subagent", + "integration": "Tích hợp", + "hooksActive": "Đang hoạt động", + "hookTip": "Đường dẫn: {{path}}\nTrạng thái: {{status}}", + "connected": "Đã kết nối", + "disconnected": "Mất kết nối", + "noHooks": "Chưa đăng ký hook dự án nào", + "wsTip": "Kết nối WebSocket: {{count}}\nGiao thức: RFC 6455", + "wsActive": "WebSocket đang hoạt động", + "connections_one": "{{count}} kết nối", + "connections_other": "{{count}} kết nối", + "platform": "Nền tảng", + "journalMode": "Chế độ journal", + "synchronous": "Đồng bộ", + "autoVacuum": "Tự dọn dẹp", + "foreignKeys": "Khóa ngoại", + "busyTimeout": "Thời gian chờ khóa" + }, + "errorBoundary": { + "title": "Đã xảy ra lỗi", + "body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.", + "reload": "Tải lại" + } } diff --git a/client/src/i18n/locales/vi/sessions.json b/client/src/i18n/locales/vi/sessions.json index c6632b18..5f302c84 100644 --- a/client/src/i18n/locales/vi/sessions.json +++ b/client/src/i18n/locales/vi/sessions.json @@ -37,7 +37,14 @@ "noEvents": "Chưa có sự kiện được ghi nhận.", "conversation": "Hội thoại", "timeline": "Dòng thời gian ({{shown}}/{{total}})", - "transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết." + "transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết.", + "back": "Quay lại", + "collapseSubagents": "Thu gọn subagent", + "expandSubagents": "Mở rộng subagent", + "dashboardRun": { + "title": "Một pane trên dashboard đang chạy phiên này", + "body": "Mở pane trong workspace để gửi tiếp, xem đầu ra hoặc dừng nó." + } }, "toolBadgeTitle": "Ghi lại bởi {{tool}}", "menu": { @@ -45,5 +52,109 @@ "inWorkspace": "Mở trong workspace của nó", "copyId": "Sao chép ID session", "copyFolder": "Sao chép đường dẫn thư mục" + }, + "exportCsv": "CSV", + "exportJson": "JSON", + "filterDirectory": "Lọc theo thư mục", + "allDirectories": "Tất cả thư mục", + "sortLabel": "Sắp xếp phiên", + "sortTimeDesc": "Theo thời gian (mới nhất)", + "sortTimeAsc": "Theo thời gian (cũ nhất)", + "sortDurationDesc": "Theo thời lượng (dài nhất)", + "sortDurationAsc": "Theo thời lượng (ngắn nhất)", + "sortPriceDesc": "Theo chi phí (cao nhất)", + "sortPriceAsc": "Theo chi phí (thấp nhất)", + "descending": "Giảm dần", + "ascending": "Tăng dần", + "filterTool": "Lọc theo công cụ AI", + "allTools": "Mọi công cụ AI", + "filterWorkspace": "Lọc theo workspace", + "allWorkspaces": "Mọi workspace", + "dateFrom": "Từ", + "dateTo": "Đến", + "clearFilters": "Xóa bộ lọc", + "failedList": "Không thể tải danh sách phiên", + "tableRegion": "Bảng phiên", + "openInWorkspace": "Mở trong workspace", + "openInWorkspaceTitle": "Một pane trên dashboard đang chạy phiên này — mở nó", + "remoteSourceBadgeTitle": "Được thu thập từ máy từ xa", + "datePicker": { + "placeholder": "Chọn ngày và giờ", + "clear": "Xóa ngày", + "prevMonth": "Tháng trước", + "nextMonth": "Tháng sau", + "time": "Giờ" + }, + "conversation": { + "failedLoad": "Không thể tải transcript", + "transcript": "Transcript", + "messages_one": "{{count}} tin nhắn", + "messages_other": "{{count}} tin nhắn", + "refresh": "Làm mới hội thoại", + "loadingHistory": "Đang tải lịch sử…", + "scrollForOlder": "Cuộn lên để xem tin nhắn cũ hơn", + "loading": "Đang tải hội thoại…", + "empty": "Không tìm thấy bản ghi hội thoại.", + "emptyBody": "Siêu dữ liệu của phiên này đã được nhập, nhưng tệp transcript không còn trên đĩa. Claude Code tự động xóa transcript của phiên không hoạt động sau một thời gian lưu giữ (cleanupPeriodDays, mặc định 30 ngày), nên các hội thoại cũ có thể đã mất. Các phiên được nhập từ nay sẽ được chụp lại và giữ nguyên ngay cả khi Claude Code xóa bản gốc.", + "newMessages": "Tin nhắn mới", + "systemReminder": "Nhắc nhở hệ thống", + "persistedOutput": "Đầu ra đã lưu", + "taskNotification": "Thông báo tác vụ", + "thinking": "Suy nghĩ", + "chars_one": "{{formatted}} ký tự", + "chars_other": "{{formatted}} ký tự", + "sender": { + "user": "Người dùng", + "assistant": "Trợ lý", + "orchestrator": "Agent chính", + "system": "Hệ thống", + "tool": "Công cụ" + }, + "lines_one": "{{count}} dòng", + "lines_other": "{{count}} dòng", + "copyCode": "Sao chép mã", + "copy": "Sao chép", + "tool": { + "inputTruncated": "Đầu vào (bị cắt bớt)", + "command": "Lệnh", + "newFile": "Tệp mới", + "replaceAll": "thay tất cả", + "removed": "Đã xóa", + "added": "Đã thêm", + "pattern": "Mẫu", + "path": "Đường dẫn", + "glob": "Glob", + "input": "Đầu vào", + "empty": "trống", + "error": "Lỗi", + "output": "Đầu ra", + "statusError": "lỗi", + "statusOk": "ok", + "statusPending": "đang chờ" + } + }, + "overview": { + "agent": "Agent", + "running": "đang chạy", + "events": "Sự kiện", + "perMin": "{{rate}}/phút", + "toolCalls": "Lần gọi công cụ", + "unique": "{{count}} loại", + "subagents": "Subagent", + "plusMain_one": "+{{count}} chính", + "plusMain_other": "+{{count}} chính", + "compactions": "Lần nén", + "errors": "Lỗi", + "duration": "Thời lượng", + "topTools": "Công cụ dùng nhiều", + "distinctTotal": "tổng {{count}}", + "noToolCalls": "Chưa có lần gọi công cụ nào.", + "contextCompaction": "Nén ngữ cảnh", + "runs_one": "{{count}} lần chạy", + "runs_other": "{{count}} lần chạy", + "noSubagents": "Phiên này không có subagent.", + "tokenFlow": "Luồng token", + "total": "tổng {{value}}", + "eventMix": "Cơ cấu sự kiện" } } diff --git a/client/src/i18n/locales/vi/workflows.json b/client/src/i18n/locales/vi/workflows.json index d2142c1a..caf49831 100644 --- a/client/src/i18n/locales/vi/workflows.json +++ b/client/src/i18n/locales/vi/workflows.json @@ -204,6 +204,15 @@ "outcomeAbandoned": "Phiên dừng mà không có Stop sạch và cũng không có lỗi rõ ràng - thường là đóng giữa chừng.", "outcomeFallback": "Trạng thái cuối của các phiên trong phạm vi, được tính từ sự kiện hook cuối quan sát được." } + }, + "legendItems": { + "sessions": "Phiên", + "mainAgent": "Agent chính", + "subagentTypes": "Loại subagent", + "compactions": "Lần nén", + "completed": "Hoàn thành", + "error": "Lỗi", + "abandoned": "Bị bỏ dở" } }, "drillIn": { @@ -230,7 +239,9 @@ "noEvents": "Không có sự kiện cho phiên này.", "showingOf": "Đang hiển thị 100 / {{total}} sự kiện", "sessionInfo": " phiên - hoạt động {{from}}%-{{to}} thời lượng phiên", - "sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)" + "sortedByCompaction": " phiên (sắp theo số lần nén ngữ cảnh)", + "openSession": "Mở phiên", + "openInWorkspace": "Mở trong workspace" }, "lanes": { "title": "Tỉ lệ pass các node pipeline", @@ -316,5 +327,7 @@ "sessions_one": "{{count}} phiên", "sessions_other": "{{count}} phiên", "pluginBreakdown": "{{skills}} skill được dùng · {{skillCalls}} lần gọi skill · {{mcpCalls}} lần gọi tool MCP" - } + }, + "selectedNode": "Đang chọn:", + "clearSelection": "Bỏ chọn" } diff --git a/client/src/lib/__tests__/exportData.test.ts b/client/src/lib/__tests__/exportData.test.ts new file mode 100644 index 00000000..b9f62e2a --- /dev/null +++ b/client/src/lib/__tests__/exportData.test.ts @@ -0,0 +1,25 @@ +/** + * @file exportData.test.ts + * @description Unit tests for the CSV export helper: quoting and formula neutralizing. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { toCsv } from "../exportData"; + +describe("toCsv", () => { + it("writes a header row and quotes cells that need it", () => { + const csv = toCsv( + [ + { id: "a", name: 'say "hi", ok', n: 3 }, + { id: "b", name: null, n: 0 }, + ], + ["id", "name", "n"] + ); + expect(csv).toBe('id,name,n\r\na,"say ""hi"", ok",3\r\nb,,0\r\n'); + }); + + it("neutralizes spreadsheet formulas", () => { + expect(toCsv([{ v: "=HYPERLINK(1)" }], ["v"])).toBe("v\r\n'=HYPERLINK(1)\r\n"); + }); +}); diff --git a/client/src/lib/__tests__/format.test.ts b/client/src/lib/__tests__/format.test.ts index 536b901a..f3f1e05f 100644 --- a/client/src/lib/__tests__/format.test.ts +++ b/client/src/lib/__tests__/format.test.ts @@ -17,6 +17,7 @@ import { formatTime, getCurrentLocale, formatModelName, + isoOrUndefined, } from "../format"; describe("formatMs", () => { @@ -256,3 +257,23 @@ describe("formatModelName", () => { expect(formatModelName("o1-preview")).toBe("O1 Preview"); }); }); + +describe("NaN guards", () => { + it("formatDuration accepts timestamps that already end in Z", () => { + expect(formatDuration("2026-03-05T10:00:00Z", "2026-03-05T10:00:05Z")).toBe("5s"); + }); + it("formatMs renders '-' for NaN", () => { + expect(formatMs(NaN)).toBe("-"); + }); + it("timeAgo renders '-' for an unparseable timestamp", () => { + expect(timeAgo("not a date")).toBe("-"); + }); +}); + +describe("isoOrUndefined", () => { + it("returns undefined for an empty or unparseable value instead of throwing", () => { + expect(isoOrUndefined("")).toBeUndefined(); + expect(isoOrUndefined("x")).toBeUndefined(); + expect(isoOrUndefined("2026-01-02T03:04:05Z")).toBe("2026-01-02T03:04:05.000Z"); + }); +}); diff --git a/client/src/lib/__tests__/throttle.test.ts b/client/src/lib/__tests__/throttle.test.ts new file mode 100644 index 00000000..0dec071c --- /dev/null +++ b/client/src/lib/__tests__/throttle.test.ts @@ -0,0 +1,41 @@ +/** + * @file throttle.test.ts + * @description Unit tests for the leading+trailing throttle used by live-refresh pages. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { throttle } from "../throttle"; + +afterEach(() => vi.useRealTimers()); + +describe("throttle", () => { + it("runs at once, then at most once per window with a trailing call", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const t = throttle(fn, 1000); + t(); + expect(fn).toHaveBeenCalledTimes(1); + // A steady stream never freezes: it fires every window, not after it stops. + for (let i = 0; i < 25; i++) { + vi.advanceTimersByTime(100); + t(); + } + expect(fn.mock.calls.length).toBeGreaterThanOrEqual(3); + vi.advanceTimersByTime(1000); + const n = fn.mock.calls.length; + vi.advanceTimersByTime(5000); + expect(fn).toHaveBeenCalledTimes(n); + }); + + it("cancel drops a pending trailing call", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const t = throttle(fn, 1000); + t(); + t(); + t.cancel(); + vi.advanceTimersByTime(2000); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/lib/exportData.ts b/client/src/lib/exportData.ts new file mode 100644 index 00000000..29602b85 --- /dev/null +++ b/client/src/lib/exportData.ts @@ -0,0 +1,39 @@ +/** + * @file exportData.ts + * @description Client-side "Download CSV / JSON" for list pages (Sessions, + * Activity, a session's events): `toCsv` turns rows into RFC 4180 CSV (and + * neutralizes spreadsheet formulas), `downloadText` saves a string as a file. + * @author Nguyễn Ngọc Trí Vĩ + */ + +/** One CSV cell: quoted when needed; a leading =, +, - or @ is prefixed with `'` + * so a spreadsheet never runs a value from a session as a formula. */ +function cell(value: unknown): string { + if (value == null) return ""; + let s = typeof value === "object" ? JSON.stringify(value) : String(value); + if (typeof value === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`; + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function toCsv( + rows: readonly T[], + columns: readonly (keyof T & string)[] +) { + const lines = [columns.join(",")]; + for (const row of rows) lines.push(columns.map((c) => cell(row[c])).join(",")); + return lines.join("\r\n") + "\r\n"; +} + +export function downloadText(filename: string, text: string, mime: string): void { + const url = URL.createObjectURL(new Blob([text], { type: mime })); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** `name-YYYY-MM-DD.ext` */ +export function exportName(name: string, ext: "csv" | "json"): string { + return `${name}-${new Date().toISOString().slice(0, 10)}.${ext}`; +} diff --git a/client/src/lib/format.ts b/client/src/lib/format.ts index be6f96e6..c49cbf86 100644 --- a/client/src/lib/format.ts +++ b/client/src/lib/format.ts @@ -163,7 +163,7 @@ import i18n from "../i18n"; * parseDate("2026-04-18T08:49:13Z") // parsed as-is * parseDate("2026-04-18T08:49:13-04:00") // parsed as-is (explicit offset) */ -function parseDate(iso: string): Date { +export function parseDate(iso: string): Date { // Already has timezone info (Z or +/- offset) - parse directly // (`/[+-]\d{2}:\d{2}$/` catches trailing `+04:00`-style offsets). if (/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso)) { @@ -297,6 +297,16 @@ export function formatDuration(start: string, end: string): string { return formatMs(ms); } +/** + * A URL/user date value as an ISO string for an API param, or `undefined` when + * it is empty or unparseable (`new Date("x").toISOString()` throws). + */ +export function isoOrUndefined(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + /** * Formats a millisecond duration as the coarsest two-unit representation: * "Nh Nm" once >= 1 hour, "Nm Ns" once >= 1 minute, else "Ns". @@ -311,6 +321,7 @@ export function formatDuration(start: string, end: string): string { * formatMs(-10) // "0s" */ export function formatMs(ms: number): string { + if (Number.isNaN(ms)) return "-"; // unparseable input upstream if (ms < 0) return "0s"; // clamp negative spans (clock skew) to zero const totalSec = Math.floor(ms / 1000); const hours = Math.floor(totalSec / 3600); // whole hours @@ -334,6 +345,7 @@ export function formatMs(ms: number): string { */ export function timeAgo(iso: string): string { const ms = Date.now() - parseDate(iso).getTime(); + if (Number.isNaN(ms)) return "-"; const seconds = Math.floor(ms / 1000); if (seconds < 60) return i18n.t("common:time.justNow"); const minutes = Math.floor(seconds / 60); diff --git a/client/src/lib/throttle.ts b/client/src/lib/throttle.ts new file mode 100644 index 00000000..d3b320fb --- /dev/null +++ b/client/src/lib/throttle.ts @@ -0,0 +1,34 @@ +/** + * @file throttle.ts + * @description Leading + trailing throttle for live-refresh callbacks. Unlike a + * trailing debounce, a steady stream of WebSocket events can never postpone the + * refresh forever: it fires at once, then at most once per `ms`, and once more + * after the burst so the last change is never lost. + * @author Nguyễn Ngọc Trí Vĩ + */ + +export interface Throttled { + (): void; + cancel(): void; +} + +export function throttle(fn: () => void, ms: number): Throttled { + let last = 0; + let timer: ReturnType | undefined; + const run = () => { + timer = undefined; + last = Date.now(); + fn(); + }; + const t = (() => { + if (timer) return; + const wait = last + ms - Date.now(); + if (wait <= 0) run(); + else timer = setTimeout(run, wait); + }) as Throttled; + t.cancel = () => { + clearTimeout(timer); + timer = undefined; + }; + return t; +} diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 2ba90fd6..32f10d5f 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -933,6 +933,12 @@ export interface DashboardEvent { /** ISO timestamp the event was recorded (ingest time, not hook-reported time). * Maps to `events.created_at`. Drives all time-bucketed charts. */ created_at: string; + /** Labels joined by `GET /api/events` only (absent on WebSocket pushes). */ + session_name?: string | null; + session_cwd?: string | null; + session_lane_id?: number | null; + agent_name?: string | null; + agent_subagent_type?: string | null; } // ───── Overview & analytics ───── @@ -1860,6 +1866,10 @@ export interface SessionStats { events_by_type: Array<{ event_type: string; count: number }>; /** Tool invocation counts within this session, most-used first. */ tools_used: Array<{ tool_name: string; count: number }>; + /** Exact tool-call total (`tools_used` is a top-15 list); absent on older servers. */ + tool_call_count?: number; + /** Number of distinct tools used; absent on older servers. */ + distinct_tool_count?: number; /** Count of events representing an error (APIError, error-summary Stop) in * this session. Drives the "N errors" badge on the detail page. */ error_count: number; @@ -2899,31 +2909,31 @@ export const STATUS_CONFIG: Record< // Green: the agent is actively running a tool / doing work. working: { labelKey: "common:status.working", - color: "text-emerald-700 dark:text-emerald-400", - bg: "bg-emerald-500/10 border-emerald-500/20", - dot: "bg-emerald-400", + color: "text-status-success", + bg: "bg-status-success/10 border-status-success/25", + dot: "bg-status-success", }, // Yellow: idle-between-turns, or (via the AWAITING_STATUS overlay) blocked // on user input - attention may be required. waiting: { labelKey: "common:status.waiting", - color: "text-yellow-700 dark:text-yellow-400", - bg: "bg-yellow-500/10 border-yellow-500/20", - dot: "bg-yellow-400", + color: "text-status-warning", + bg: "bg-status-warning/10 border-status-warning/25", + dot: "bg-status-warning", }, - // Violet: the agent finished cleanly. + // Accent: the agent finished cleanly. completed: { labelKey: "common:status.completed", - color: "text-violet-700 dark:text-violet-400", - bg: "bg-violet-500/10 border-violet-500/20", - dot: "bg-violet-400", + color: "text-accent", + bg: "bg-accent/10 border-accent/25", + dot: "bg-accent", }, // Red: the agent ended in an error state. error: { labelKey: "common:status.error", - color: "text-red-700 dark:text-red-400", - bg: "bg-red-500/10 border-red-500/20", - dot: "bg-red-400", + color: "text-status-danger", + bg: "bg-status-danger/10 border-status-danger/25", + dot: "bg-status-danger", }, }; @@ -3107,30 +3117,30 @@ export const SESSION_STATUS_CONFIG: Record< // Green: the session is live and running. active: { labelKey: "common:status.active", - color: "text-emerald-700 dark:text-emerald-400", - bg: "bg-emerald-500/10 border-emerald-500/20", - dot: "bg-emerald-400", + color: "text-status-success", + bg: "bg-status-success/10 border-status-success/25", + dot: "bg-status-success", }, // Yellow: blocked on user input via the AWAITING_STATUS overlay. waiting: { labelKey: "common:status.waiting", - color: "text-yellow-700 dark:text-yellow-400", - bg: "bg-yellow-500/10 border-yellow-500/20", - dot: "bg-yellow-400", + color: "text-status-warning", + bg: "bg-status-warning/10 border-status-warning/25", + dot: "bg-status-warning", }, - // Violet: the session ended cleanly (SessionEnd). + // Accent: the session ended cleanly (SessionEnd). completed: { labelKey: "common:status.completed", - color: "text-violet-700 dark:text-violet-400", - bg: "bg-violet-500/10 border-violet-500/20", - dot: "bg-violet-400", + color: "text-accent", + bg: "bg-accent/10 border-accent/25", + dot: "bg-accent", }, // Red: the session ended in an error state. error: { labelKey: "common:status.error", - color: "text-red-700 dark:text-red-400", - bg: "bg-red-500/10 border-red-500/20", - dot: "bg-red-400", + color: "text-status-danger", + bg: "bg-status-danger/10 border-status-danger/25", + dot: "bg-status-danger", }, // Slate: stale session swept as "abandoned" (never cleanly ended); has no // equivalent in STATUS_CONFIG since agents don't get abandoned. @@ -3138,8 +3148,8 @@ export const SESSION_STATUS_CONFIG: Record< // Muted slate distinguishes "given up / faded out" from yellow Waiting // (attention required). labelKey: "common:status.abandoned", - color: "text-slate-600 dark:text-slate-400", - bg: "bg-slate-500/10 border-slate-500/20", - dot: "bg-slate-400", + color: "text-fg-secondary", + bg: "bg-surface-3 border-border", + dot: "bg-fg-muted", }, }; diff --git a/client/src/pages/ActivityFeed.tsx b/client/src/pages/ActivityFeed.tsx index 42d23233..a0e5d09c 100644 --- a/client/src/pages/ActivityFeed.tsx +++ b/client/src/pages/ActivityFeed.tsx @@ -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(); +export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) { + const eventCtx = useContextMenu(); const { t } = useTranslation("activity"); const [events, setEvents] = useState([]); const [total, setTotal] = useState(0); - const [page, setPage] = useState(0); - const [filters, setFilters] = useState(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(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>(() => 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>(() => 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>( - () => 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>(() => new Map()); + const knownAgentSessions = useRef(new Set()); - const bufferRef = useRef([]); const pausedRef = useRef(paused); - // Refs let the websocket handler read the latest filter/page without - // re-subscribing on every change. - const apiParamsRef = useRef>({}); - const pageRef = useRef(0); - const refreshTimerRef = useRef | 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(); - const projectMap = new Map(); - 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(); - 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(); + 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(); - 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 (
{eventCtx.menu && (
-
- -
+ {!embedded && ( +
+ +
+ )}
-

{t("title")}

+ {/* Under History's tab bar the tab already names the page. */} +

+ {t("title")} +

{wsConnected ? ( @@ -353,9 +366,7 @@ export function ActivityFeed() {

{t("subtitle")} {paused && ( - - {t("paused", { count: bufferCount })} - + {t("paused", { count: newCount })} )}

@@ -372,7 +383,18 @@ export function ActivityFeed() { )} - + +
@@ -393,6 +415,29 @@ export function ActivityFeed() { />
+ {error && ( +
+ + {t("failedLoad")}: {error} + + +
+ )} + + {!paused && page > 0 && newCount > 0 && ( + + )} + {!loading && events.length === 0 ? ( + {/* The row is a plain container; its expand control is a real + button and "Session" a sibling link (no nested interactives). */}
{ + 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" > - +
@@ -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")} > « @@ -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")} > » diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index d8126e37..34033b55 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -61,10 +61,11 @@ import { useSyncExternalStore, useMemo, useRef, + lazy, + Suspense, type ReactNode, } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { Workflows } from "./Workflows"; +import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { LayoutDashboard, @@ -89,20 +90,25 @@ import { Search, Download, Clock, + AlertCircle, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; +import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { StatCard } from "../components/StatCard"; import { AgentCard } from "../components/AgentCard"; import { useContextMenu } from "../components/ContextMenu"; import { SessionRowMenu } from "../components/SessionRowMenu"; +import { NeedsYouStrip } from "../components/NeedsYouStrip"; +import { RouteErrorBoundary } from "../components/RouteErrorBoundary"; import { AgentStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { Tip } from "../components/Tip"; -import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton"; +import { Skeleton } from "../components/Skeleton"; import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; +import { statusFromEventType } from "../lib/event-grouping"; import type { Stats, Agent, @@ -379,7 +385,7 @@ function Heatmap({ weeks }: { weeks: Array; color?: string; @@ -418,7 +424,7 @@ function AnalyticsSparkline({ function CostTrendLine({ data, - color = "#10b981", + color = "rgb(var(--status-success))", }: { data: Array<{ date: string; cost: number }>; color?: string; @@ -648,45 +654,6 @@ function DonutChart({ ); } -function StatPill({ - label, - value, - raw, - sub, - icon: Icon, - color = "text-accent", - loading = false, -}: { - label: string; - value: string | number; - raw?: string; - sub?: string; - icon: React.ElementType; - color?: string; - loading?: boolean; -}) { - return ( -
-
- {label} - -
- {loading ? ( - - ) : ( -

- {raw ? {value} : value} -

- )} - {loading ? ( - - ) : ( - sub &&

{sub}

- )} -
- ); -} - /** A single chart-card placeholder: title bar, chart body, two footer lines. * Uses the shared pulsing so the whole grid shimmers while data * loads (mirrors the chart-card layout so nothing jumps when data arrives). */ @@ -698,7 +665,7 @@ function ChartCardSkeleton({ bodyH?: string; }) { return ( -
+
@@ -714,7 +681,7 @@ function ChartCardSkeleton({ * still being fetched. */ function AnalyticsChartsSkeleton() { return ( -
+
@@ -736,49 +703,64 @@ function AnalyticsTab() { const [lastUpdate, setLastUpdate] = useState(new Date()); const [loading, setLoading] = useState(true); // `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`). - const [params] = useSearchParams(); - const [analyticsSubTab, setAnalyticsSubTab] = useState< - "tokens" | "cost" | "workflow" | "productivity" - >(() => { - const sub = params.get("sub"); - return sub === "tokens" || sub === "workflow" || sub === "productivity" ? sub : "cost"; - }); + // The URL is the state: `?sub=` is read on every render and written back. + const [params, setParams] = useSearchParams(); + const subParam = params.get("sub"); + const analyticsSubTab: "tokens" | "cost" | "workflow" | "productivity" = + subParam === "tokens" || subParam === "workflow" || subParam === "productivity" + ? subParam + : "cost"; + const setAnalyticsSubTab = (sub: typeof analyticsSubTab) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("tab", "analytics"); + if (sub === "cost") next.delete("sub"); + else next.set("sub", sub); + return next; + }, + { replace: true } + ); // Global data scope; a change re-runs `load` (api injects the `sources` param). const [scope] = useDataScope(); + const [loadError, setLoadError] = useState(null); + // Responses can land out of order (a slow poll after a fast refresh); only + // the newest request may write state. + const loadSeq = useRef(0); const load = useCallback(async () => { + const seq = ++loadSeq.current; try { const [result, cost] = await Promise.all([ api.analytics.get(), api.pricing.totalCost().catch(() => null), ]); + if (seq !== loadSeq.current) return; setData(result); setCostData(cost); + setLoadError(null); setLastUpdate(new Date()); + } catch (err) { + if (seq === loadSeq.current) + setLoadError(err instanceof Error ? err.message : t("dashboard:failedLoad")); } finally { - setLoading(false); + if (seq === loadSeq.current) setLoading(false); } }, [scope]); useEffect(() => { load(); - const interval = setInterval(load, 15000); - return () => clearInterval(interval); }, [load]); - useEffect(() => { - return eventBus.subscribe((msg) => { - if ( - msg.type === "session_created" || - msg.type === "session_updated" || - msg.type === "new_event" || - msg.type === "agent_created" || - isRemoteDataRefreshMessage(msg) - ) { - load(); - } - }); - }, [load]); + useLiveRefresh(load, { + intervalMs: 15000, + match: (msg) => + msg.type === "session_created" || + msg.type === "session_updated" || + msg.type === "new_event" || + msg.type === "agent_created" || + isRemoteDataRefreshMessage(msg), + }); function handleExport() { if (!data) return; @@ -848,6 +830,14 @@ function AnalyticsTab() { dailySessionsLocal.push({ date, count }); } dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date)); + // Calendar days, zero-filled: "last 30/7 days" means days, not active days. + const sessionsLast30 = Array.from({ length: 30 }, (_, i) => { + const d = new Date(today); + d.setDate(today.getDate() - (29 - i)); + const dateStr = localDateStr(d); + return { date: dateStr, count: sessMap[dateStr] ?? 0 }; + }); + const maxSessionsDay = Math.max(1, ...sessionsLast30.map((d) => d.count)); // Convert daily_costs - server already returns local dates const dailyCostsLocal: Array<{ date: string; cost: number }> = []; @@ -876,13 +866,15 @@ function AnalyticsTab() { .sort((a, b) => b.cost - a.cost); const locale = i18n.resolvedLanguage ?? i18n.language; const weekdayOrder = [1, 2, 3, 4, 5, 6, 0]; + // 2026-01-04 is a Sunday; a LOCAL date, so the label never shifts a day + // west of UTC. 30 days hold some weekdays 5 times and others 4, so the bar + // is the average per occurrence, not the sum. const weekdayCosts = weekdayOrder.map((dow) => { const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format( - new Date(Date.UTC(2026, 0, 4 + dow)) + new Date(2026, 0, 4 + dow, 12) ); - const cost = dailyCostLast30 - .filter((day) => new Date(day.date + "T12:00:00").getDay() === dow) - .reduce((sum, day) => sum + day.cost, 0); + const days = dailyCostLast30.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow); + const cost = days.length ? days.reduce((sum, day) => sum + day.cost, 0) / days.length : 0; return { label, cost }; }); const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1); @@ -893,17 +885,25 @@ function AnalyticsTab() { (data?.tokens.total_cache_read ?? 0) + (data?.tokens.total_cache_write ?? 0); const tokenMixSegments = [ - { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "#60a5fa" }, - { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "#34d399" }, + { + label: t("common:token.input"), + value: data?.tokens.total_input ?? 0, + color: "rgb(var(--syn-builtin))", + }, + { + label: t("common:token.output"), + value: data?.tokens.total_output ?? 0, + color: "rgb(var(--status-success))", + }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, - color: "#a78bfa", + color: "rgb(var(--syn-keyword))", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, - color: "#facc15", + color: "rgb(var(--status-warning))", }, ].filter((s) => s.value > 0); @@ -911,29 +911,37 @@ function AnalyticsTab() { const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1; const maxEventTypeCount = data?.event_types[0]?.count ?? 1; + // Hit rate = cache reads over every prompt token (fresh input + cache read + + // cache write); output tokens are never cacheable. + const promptTokens = + (data?.tokens.total_input ?? 0) + + (data?.tokens.total_cache_read ?? 0) + + (data?.tokens.total_cache_write ?? 0); const cacheHitPct = - totalTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / totalTokens) * 100) : 0; + promptTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / promptTokens) * 100) : 0; + // The headline excludes cache reads, which dwarf everything else and cost a tenth. + const nonCacheReadTokens = totalTokens - (data?.tokens.total_cache_read ?? 0); const sessionOutcomeSegments = [ { label: t("common:status.completed"), value: data?.sessions_by_status?.completed ?? 0, - color: "#8b5cf6", + color: "rgb(var(--syn-keyword))", }, { label: t("common:status.active"), value: data?.sessions_by_status?.active ?? 0, - color: "#10b981", + color: "rgb(var(--status-success))", }, { label: t("common:status.error"), value: data?.sessions_by_status?.error ?? 0, - color: "#ef4444", + color: "rgb(var(--status-danger))", }, { label: t("common:status.abandoned"), value: data?.sessions_by_status?.abandoned ?? 0, - color: "#f59e0b", + color: "rgb(var(--status-warning))", }, ].filter((s) => s.value > 0); @@ -941,31 +949,31 @@ function AnalyticsTab() { { label: t("common:status.completed"), value: data?.agents_by_status?.completed ?? 0, - color: "#8b5cf6", + color: "rgb(var(--syn-keyword))", }, { label: t("common:status.working"), value: data?.agents_by_status?.working ?? 0, - color: "#10b981", + color: "rgb(var(--status-success))", }, { label: t("common:status.waiting"), value: data?.agents_by_status?.waiting ?? 0, - color: "#eab308", + color: "rgb(var(--status-warning))", }, { label: t("common:status.error"), value: data?.agents_by_status?.error ?? 0, - color: "#ef4444", + color: "rgb(var(--status-danger))", }, ].filter((s) => s.value > 0); const EVENT_TYPE_COLORS: Record = { PreToolUse: "bg-status-success", - PostToolUse: "bg-blue-500", - Stop: "bg-violet-400", - SubagentStop: "bg-yellow-400", - Notification: "bg-orange-400", + PostToolUse: "bg-syntax-builtin", + Stop: "bg-syntax-keyword", + SubagentStop: "bg-status-warning", + Notification: "bg-syntax-number", }; return ( @@ -988,55 +996,77 @@ function AnalyticsTab() {
+ {loadError && ( +
+ + {loadError} + +
+ )} + {/* Key stats */}
- - - - -
@@ -1049,7 +1079,12 @@ function AnalyticsTab() {

{t("eventActivity")}

-
+
@@ -1100,6 +1135,7 @@ function AnalyticsTab() {
@@ -1176,7 +1210,7 @@ function AnalyticsTab() { { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, - color: "text-blue-500", + color: "text-syntax-builtin", }, { label: t("common:token.output"), @@ -1186,12 +1220,12 @@ function AnalyticsTab() { { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, - color: "text-violet-700 dark:text-violet-400", + color: "text-syntax-keyword", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, - color: "text-yellow-700 dark:text-yellow-400", + color: "text-status-warning", }, { label: t("common:total"), value: totalTokens, color: "text-fg-primary" }, ].map(({ label, value, color }) => ( @@ -1282,9 +1316,14 @@ function AnalyticsTab() { label: formatModelName(b.model) ?? b.model, value: Math.round(b.cost * 100), color: - ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][ - i % 6 - ] ?? "#6b7280", + [ + "rgb(var(--syn-keyword))", + "rgb(var(--syn-builtin))", + "rgb(var(--status-success))", + "rgb(var(--status-warning))", + "rgb(var(--status-danger))", + "rgb(var(--syn-operator))", + ][i % 6] ?? "rgb(var(--fg-muted))", }))} formatTotal={(cents) => fmtCost(cents / 100)} /> @@ -1323,7 +1362,7 @@ function AnalyticsTab() {

{t("noDailyCostData")}

) : ( <> -

{t("last30Days")}

+

{t("avgPerWeekday30d")}

{weekdayCosts.map(({ label, cost }) => ( ))}
{t("common:total")} - + {fmtCost(totalCost30d)}
@@ -1365,7 +1404,7 @@ function AnalyticsTab() { label={subagent_type} count={count} max={maxAgentTypeCount} - color="bg-violet-400" + color="bg-syntax-keyword" /> ))}
@@ -1430,7 +1469,11 @@ function AnalyticsTab() {
{/* The former Workflows page, folded into this sub-tab. */} - + + }> + + +
)} @@ -1449,7 +1492,7 @@ function AnalyticsTab() { label={tool_name} count={count} max={maxToolCount} - color="bg-yellow-400" + color="bg-status-warning" /> ))}
@@ -1500,18 +1543,13 @@ function AnalyticsTab() {

{t("noSessionTrendData")}

) : ( <> - +
- {dailySessionsLocal + {sessionsLast30 .slice(-7) .reverse() .map(({ date, count }) => { - const maxD = Math.max( - ...(dailySessionsLocal.length > 0 - ? dailySessionsLocal - : [{ count: 1 }] - ).map((d) => d.count) - ); + const maxD = maxSessionsDay; return (
@@ -1546,24 +1584,27 @@ function AnalyticsTab() { } function SystemHealthTab() { + const { t } = useTranslation("dashboard"); const [info, setInfo] = useState(null); const [workflow, setWorkflow] = useState(null); + const [loadError, setLoadError] = useState(null); const loadData = useCallback(async () => { try { const [infoRes, workflowRes] = await Promise.all([api.settings.info(), api.workflows.get()]); setInfo(infoRes as any); setWorkflow(workflowRes); + setLoadError(null); } catch (e) { - console.error(e); + setLoadError(e instanceof Error ? e.message : String(e)); } }, []); useEffect(() => { loadData(); - const int = setInterval(loadData, 30000); // 30s is sufficient for health metrics - return () => clearInterval(int); }, [loadData]); + // 30s is sufficient for health metrics; skipped while the tab is hidden. + useLiveRefresh(loadData, { intervalMs: 30000 }); const stats = useMemo(() => { if (!info || !workflow) return null; @@ -1576,7 +1617,7 @@ function SystemHealthTab() { const agentPct = totalEntries > 0 ? ((info.db.counts?.agents || 0) / totalEntries) * 100 : 0; const eventPct = totalEntries > 0 ? ((info.db.counts?.events || 0) / totalEntries) * 100 : 0; - const modelStats = (workflow.modelDelegation?.tokensByModel || []) + const modelStats = [...(workflow.modelDelegation?.tokensByModel || [])] .sort((a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens)) .slice(0, 6); const totalTokens = modelStats.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0); @@ -1600,6 +1641,20 @@ function SystemHealthTab() { }; }, [info, workflow]); + if (loadError && !info) { + return ( +
+ +

+ {t("failedLoad")}: {loadError} +

+ +
+ ); + } + if (!info || !workflow || !stats) { return (
@@ -1667,22 +1722,28 @@ function SystemHealthTab() {
- Runtime + + {t("health.runtime")} +
- {info.server.cpus} cores · {info.server.arch} + {t("health.cores", { count: info.server.cpus })} · {info.server.arch}
- Uptime + + {t("health.uptime")} + {formatUptime(info.server.uptime)}
- CPU (1/5/15m) + + {t("health.cpu")} +
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
- Node RSS + + {t("health.nodeRss")} + {formatBytes(info.server.memory.rss)} @@ -1705,11 +1768,15 @@ function SystemHealthTab() {
- Host Memory + {t("health.hostMemory")} {memUsedPct.toFixed(0)}%
@@ -1722,16 +1789,22 @@ function SystemHealthTab() {
- V8 Heap + {t("health.heap")} {heapUsedPct.toFixed(0)}%
85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-blue-600"}`} + className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-syntax-builtin"}`} style={{ width: `${heapUsedPct}%` }} />
@@ -1744,11 +1817,17 @@ function SystemHealthTab() {
- - Storage + + + {t("health.storage")} +
⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/ @@ -1758,7 +1837,9 @@ function SystemHealthTab() {
- Database + + {t("health.database")} + {formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"} @@ -1767,7 +1848,15 @@ function SystemHealthTab() { {/* Pie chart + legend using same pattern as Analytics DonutChart */}
{ @@ -1825,7 +1914,7 @@ function SystemHealthTab() { fontWeight="700" fontFamily="monospace" > - {totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries} + {fmt(totalEntries)} - total + {t("health.total")}
{[ { - label: "Sessions", + label: t("health.sessions"), value: info.db.counts?.sessions ?? 0, - color: "#60a5fa", + color: "rgb(var(--syn-builtin))", pct: sessPct, }, { - label: "Agents", + label: t("health.agents"), value: info.db.counts?.agents ?? 0, - color: "#8b5cf6", + color: "rgb(var(--syn-keyword))", pct: agentPct, }, { - label: "Events", + label: t("health.events"), value: info.db.counts?.events ?? 0, - color: "#34d399", + color: "rgb(var(--status-success))", pct: eventPct, }, ].map((item) => ( @@ -1886,13 +1975,21 @@ function SystemHealthTab() {
- Health Score + + {t("health.score")} +
- ⓘ Formula + ⓘ {t("health.formula")}
@@ -1900,7 +1997,13 @@ function SystemHealthTab() { {/* Ring gauge - centered */}
= 90 ? "#34d399" : healthScore >= 70 ? "#fbbf24" : "#f87171"} + stroke={ + healthScore >= 90 + ? "rgb(var(--status-success))" + : healthScore >= 70 + ? "rgb(var(--status-warning))" + : "rgb(var(--status-danger))" + } strokeWidth="10" strokeLinecap="round" strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`} @@ -1954,21 +2063,22 @@ function SystemHealthTab() {
-

Cache

-

+

{t("health.cache")}

+

{cacheHitRate.toFixed(0)}%

- 15% = critical`} - > +
-

Errors

+

{t("health.errors")}

@@ -1978,23 +2088,25 @@ function SystemHealthTab() {

-

Compact

-

+

{t("health.compact")}

+

{workflow.compaction?.totalCompactions ?? 0}

-

Saved

+

{t("health.saved")}

- {((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K + {fmt(workflow.compaction?.tokensRecovered ?? 0)}

@@ -2008,11 +2120,13 @@ function SystemHealthTab() {
- - Token Usage + + + {t("health.tokenUsage")} +
- {(totalTokens / 1000).toFixed(1)}K total + {t("health.totalSuffix", { value: fmt(totalTokens) })}
@@ -2021,18 +2135,24 @@ function SystemHealthTab() { const pct = totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0; const colors = [ - "bg-blue-500", - "bg-violet-400", + "bg-syntax-builtin", + "bg-syntax-keyword", "bg-status-success", "bg-status-warning", - "bg-pink-400", - "bg-cyan-400", + "bg-syntax-operator", + "bg-syntax-property", ]; return (
No model data

+

{t("health.noModelData")}

)}
@@ -2064,16 +2184,27 @@ function SystemHealthTab() {
- - Concurrency + + + {t("health.concurrency")} +
- {lanes.length} intervals + + {t("health.intervals", { count: lanes.length })} +
{/* Sparkline-style bar chart matching Analytics sparkline */} l.count > 0).length}\nAvg: ${lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"}`} + raw={t("health.concurrencyTip", { + peak: maxLaneCount, + active: lanes.filter((l) => l.count > 0).length, + avg: + lanes.length > 0 + ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) + : "0", + })} >
{lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => { @@ -2081,11 +2212,11 @@ function SystemHealthTab() { maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4; const color = lane.count > 5 - ? "#f87171" + ? "rgb(var(--status-danger))" : lane.count > 2 - ? "#fbbf24" + ? "rgb(var(--status-warning))" : lane.count > 0 - ? "#34d399" + ? "rgb(var(--status-success))" : "rgb(var(--border))"; return (
- No concurrency data + {t("health.noConcurrencyData")}

)}
- +
-

Peak

+

{t("health.peak")}

{maxLaneCount}

l.count > 0).length} of ${lanes.length} intervals have active sessions.`} + raw={t("health.activeTip", { + active: lanes.filter((l) => l.count > 0).length, + total: lanes.length, + })} >
-

Active

+

{t("health.active")}

{lanes.filter((l) => l.count > 0).length}

- +
-

Avg

-

+

{t("health.avg")}

+

{lanes.length > 0 ? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1) : "0"} @@ -2146,9 +2280,13 @@ function SystemHealthTab() {

- Tool Usage + + {t("health.toolUsage")} +
- top {topTools.length} + + {t("health.top", { count: topTools.length })} +
@@ -2156,19 +2294,23 @@ function SystemHealthTab() { const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0; const colors = [ "bg-status-warning", - "bg-blue-500", + "bg-syntax-builtin", "bg-status-success", - "bg-violet-400", - "bg-pink-400", - "bg-cyan-400", + "bg-syntax-keyword", + "bg-syntax-operator", + "bg-syntax-property", "bg-status-danger", - "bg-indigo-400", + "bg-accent", ]; return (
- {tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count} + {fmt(tool.count)}
); })} {topTools.length === 0 && ( -

No tool data yet

+

{t("health.noToolData")}

)}
@@ -2200,9 +2342,9 @@ function SystemHealthTab() {
- + - Subagent Effectiveness + {t("health.subagentEffectiveness")}
@@ -2219,11 +2361,17 @@ function SystemHealthTab() {
- {item.subagent_type || "default"} + {item.subagent_type || t("health.defaultType")}
No subagent data yet

+

{t("health.noSubagentData")}

)}
@@ -2254,12 +2402,14 @@ function SystemHealthTab() {
- Integration + + {t("health.integration")} +
- {info.hooks.installed ? "Active" : "Offline"} + {info.hooks.installed ? t("health.hooksActive") : t("common:offline")}
@@ -2269,7 +2419,10 @@ function SystemHealthTab() {
-

No project hooks registered

+

{t("health.noHooks")}

)} - +
-

WebSocket Active

+

+ {t("health.wsActive")} +

- {info.server.ws_connections} connection - {info.server.ws_connections !== 1 ? "s" : ""} + {t("health.connections", { count: info.server.ws_connections })}

@@ -2310,8 +2461,10 @@ function SystemHealthTab() {
- - Platform + + + {t("health.platform")} +
{info.server.node_version}
@@ -2319,11 +2472,11 @@ function SystemHealthTab() {
{[ { - label: "Journal Mode", + label: t("health.journalMode"), value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL", }, { - label: "Synchronous", + label: t("health.synchronous"), value: info.db.pragmas?.synchronous === 2 ? "FULL" @@ -2331,10 +2484,22 @@ function SystemHealthTab() { ? "NORMAL" : "OFF", }, - { label: "Auto-Vacuum", value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF" }, - { label: "Foreign Keys", value: info.db.pragmas?.foreign_keys ? "ON" : "OFF" }, - { label: "Busy Timeout", value: `${info.db.pragmas?.busy_timeout || 5000}ms` }, - { label: "Platform", value: `${info.server.platform} / ${info.server.arch}` }, + { + label: t("health.autoVacuum"), + value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF", + }, + { + label: t("health.foreignKeys"), + value: info.db.pragmas?.foreign_keys ? "ON" : "OFF", + }, + { + label: t("health.busyTimeout"), + value: `${info.db.pragmas?.busy_timeout || 5000}ms`, + }, + { + label: t("health.platform"), + value: `${info.server.platform} / ${info.server.arch}`, + }, ].map((row) => (
{row.label} @@ -2357,50 +2522,32 @@ function SystemHealthTab() { ); } -export function Dashboard() { - const navigate = useNavigate(); +// The Workflow sub-tab (d3 charts) loads on first open, not with the Dashboard. +const Workflows = lazy(() => import("./Workflows").then((m) => ({ default: m.Workflows }))); + +/** Rows kept in the Monitor's recent-activity list, on load and on a push alike. */ +const RECENT_LIMIT = 30; + +/** + * The Monitor's recent-activity column. Its own component (own state, own + * fetch and push subscription) so a new event re-renders this list only, not + * the whole Dashboard and its agent tree. + */ +function RecentActivityPanel({ sessionsById }: { sessionsById: Map }) { const { t } = useTranslation("dashboard"); const eventCtx = useContextMenu(); - - // Persistent Tab State - const [tabParams] = useSearchParams(); - const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => { - const tab = tabParams.get("tab"); - if (tab === "monitor" || tab === "analytics" || tab === "health") return tab; - return ( - (localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "analytics" - ); - }); - - useEffect(() => { - localStorage.setItem("dashboard_tab", activeTab); - }, [activeTab]); - - const [stats, setStats] = useState(null); - const [activeAgents, setActiveAgents] = useState([]); const [recentEvents, setRecentEvents] = useState([]); - const [totalCost, setTotalCost] = useState(null); - const [allSubagents, setAllSubagents] = useState([]); - const [sessionsById, setSessionsById] = useState>(new Map()); - const [expandedAgents, setExpandedAgents] = useState>(new Set()); - const [error, setError] = useState(null); + const [scope] = useDataScope(); - // Dynamic item counts based on available container height - const agentsContainerRef = useRef(null); + // Dynamic row count based on the column's available height. const activityContainerRef = useRef(null); - const [visibleAgentCount, setVisibleAgentCount] = useState(5); const [visibleActivityCount, setVisibleActivityCount] = useState(8); useEffect(() => { - const AGENT_ROW_H = 56; // ~px per agent card row const ACTIVITY_ROW_H = 44; // ~px per activity row const HEADER_H = 40; // section header height function recalc() { - if (agentsContainerRef.current) { - const h = agentsContainerRef.current.clientHeight; - setVisibleAgentCount(Math.max(3, Math.floor((h - HEADER_H) / AGENT_ROW_H))); - } if (activityContainerRef.current) { const h = activityContainerRef.current.clientHeight; setVisibleActivityCount(Math.max(3, Math.floor((h - HEADER_H) / ACTIVITY_ROW_H))); @@ -2408,56 +2555,205 @@ export function Dashboard() { } const ro = new ResizeObserver(recalc); - if (agentsContainerRef.current) ro.observe(agentsContainerRef.current); if (activityContainerRef.current) ro.observe(activityContainerRef.current); recalc(); return () => ro.disconnect(); - }, [activeTab]); + }, []); + + const load = useCallback(() => { + api.events + .list({ limit: RECENT_LIMIT }) + .then((r) => setRecentEvents(r.events)) + .catch(() => undefined); // a failed refresh keeps the last list + }, [scope]); + useEffect(() => { + load(); + }, [load]); + useLiveRefresh(load, { intervalMs: 10000 }); + + // A new event is prepended at once (no reload); the poll reconciles. + useEffect( + () => + eventBus.subscribe((msg: WSMessage) => { + if (msg.type !== "new_event") return; + const newEvent = msg.data as DashboardEvent; + setRecentEvents((prev) => { + // Deduplicate by event ID to prevent WS + polling race condition + if (newEvent.id && prev.some((e) => e.id === newEvent.id)) return prev; + return [newEvent, ...prev].slice(0, RECENT_LIMIT); + }); + }), + [] + ); + + return ( +
+
+

{t("recentActivity")}

+ + {t("viewAll")} + +
+ {recentEvents.length === 0 ? ( + + ) : ( +
+ {eventCtx.menu && ( + + )} + {recentEvents.slice(0, visibleActivityCount).map((event, i) => ( + eventCtx.open(e, event.session_id)} + > + + + {event.summary || event.event_type} + + {(() => { + // Session label: real name when one exists, else the + // short ID - keeps every activity row attributable. + const sname = + (event.session_name ?? sessionsById.get(event.session_id)?.name)?.trim() || ""; + const isAuto = /^Session [0-9a-f]{8}$/i.test(sname); + return ( + + {sname && !isAuto ? ( + sname + ) : ( + {event.session_id.slice(0, 8)} + )} + + ); + })()} + {event.tool_name && ( + {event.tool_name} + )} + + {timeAgo(event.created_at)} + + + ))} +
+ )} +
+ ); +} + +type DashboardTab = "monitor" | "analytics" | "health"; +function isDashboardTab(v: string | null): v is DashboardTab { + return v === "monitor" || v === "analytics" || v === "health"; +} + +export function Dashboard() { + const { t } = useTranslation("dashboard"); + + // Tab state: `?tab=` wins, else the last tab used, else Analytics. Written + // back to the URL (replace) so a reload or a shared link lands on it. + const [tabParams, setTabParams] = useSearchParams(); + const urlTab = tabParams.get("tab"); + const storedTab = localStorage.getItem("dashboard_tab"); + const activeTab: DashboardTab = isDashboardTab(urlTab) + ? urlTab + : isDashboardTab(storedTab) + ? storedTab + : "analytics"; + const setActiveTab = (tab: DashboardTab) => { + localStorage.setItem("dashboard_tab", tab); + setTabParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("tab", tab); + if (tab !== "analytics") next.delete("sub"); + return next; + }, + { replace: true } + ); + }; + + const [stats, setStats] = useState(null); + const [activeAgents, setActiveAgents] = useState([]); + const [totalCost, setTotalCost] = useState(null); + const [allSubagents, setAllSubagents] = useState([]); + const [sessionsById, setSessionsById] = useState>(new Map()); + const [expandedAgents, setExpandedAgents] = useState>(new Set()); + // Calls that failed on the last load; each panel keeps its last good data. + const [error, setError] = useState(null); + // The agents column scrolls, so no main agent is ever dropped. + const agentsContainerRef = useRef(null); // Global data scope; a change re-runs `load` (the api layer injects the // matching `sources` param into the calls below). const [scope] = useDataScope(); + const monitorSeq = useRef(0); const load = useCallback(async () => { - try { - const [statsRes, workingRes, waitingRes, eventsRes, costRes, sessionsRes] = await Promise.all( - [ - api.stats.get(), - api.agents.list({ status: "working", limit: 20 }), - api.agents.list({ status: "waiting", limit: 20 }), - api.events.list({ limit: 30 }), - api.pricing.totalCost(), - api.sessions.list({ status: "active", limit: 100 }), - ] - ); - setStats(statsRes); + const seq = ++monitorSeq.current; + const failed: string[] = []; + // One failing endpoint must not blank the others: each call keeps its own + // catch and the panel it feeds keeps its last good value. + const soft = (p: Promise): Promise => + p.catch((err: unknown) => { + failed.push(err instanceof Error ? err.message : String(err)); + return null; + }); + const [statsRes, workingRes, waitingRes, costRes, sessionsRes] = await Promise.all([ + soft(api.stats.get()), + soft(api.agents.list({ status: "working", limit: 20 })), + soft(api.agents.list({ status: "waiting", limit: 20 })), + soft(api.pricing.totalCost()), + soft(api.sessions.list({ status: "active", limit: 100 })), + ]); + if (seq !== monitorSeq.current) return; + if (statsRes) setStats(statsRes); + if (costRes) setTotalCost(costRes.total_cost); + if (sessionsRes) setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s]))); + if (workingRes && waitingRes) { const active = [...workingRes.agents, ...waitingRes.agents]; setActiveAgents(active); - setRecentEvents(eventsRes.events); - setTotalCost(costRes.total_cost); - setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s]))); - setError(null); - - // Fetch all subagents for each active main agent's session + // Every subagent of the active main agents' sessions, in ONE call. const activeSessionIds = [ ...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)), ]; - const subagentResults = await Promise.all( - activeSessionIds.map((sid) => api.agents.list({ session_id: sid, limit: 100 })) - ); - const subs = subagentResults.flatMap((r) => r.agents).filter((a) => a.type === "subagent"); - setAllSubagents(subs); - } catch (err) { - setError(err instanceof Error ? err.message : t("failedLoad")); + const subsRes = activeSessionIds.length + ? await soft(api.agents.list({ session_ids: activeSessionIds, limit: 2000 })) + : { agents: [] }; + if (seq !== monitorSeq.current) return; + if (subsRes) setAllSubagents(subsRes.agents.filter((a) => a.type === "subagent")); } - }, [t, scope]); + setError(failed[0] ?? null); + }, [scope]); + const monitorOn = activeTab === "monitor"; useEffect(() => { - load(); - const interval = setInterval(load, 10000); - return () => clearInterval(interval); - }, [load]); + if (monitorOn) load(); + }, [load, monitorOn]); + + useLiveRefresh(load, { + enabled: monitorOn, + intervalMs: 10000, + match: (msg) => + msg.type === "agent_created" || + msg.type === "agent_updated" || + msg.type === "session_created" || + msg.type === "session_updated" || + isRemoteDataRefreshMessage(msg), + }); // Auto-expand agents with active subagents (walk up the full parent chain) useEffect(() => { @@ -2487,31 +2783,6 @@ export function Dashboard() { }); }, [allSubagents]); - useEffect(() => { - const debounceRef = { timer: null as ReturnType | null }; - return eventBus.subscribe((msg: WSMessage) => { - if ( - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "session_created" || - msg.type === "session_updated" || - isRemoteDataRefreshMessage(msg) - ) { - // Debounce rapid-fire updates (e.g., 5 agents created in 100ms) - if (debounceRef.timer) clearTimeout(debounceRef.timer); - debounceRef.timer = setTimeout(load, 300); - } - if (msg.type === "new_event") { - setRecentEvents((prev) => { - const newEvent = msg.data as DashboardEvent; - // Deduplicate by event ID to prevent WS + polling race condition - if (newEvent.id && prev.some((e) => e.id === newEvent.id)) return prev; - return [newEvent, ...prev.slice(0, 14)]; - }); - } - }); - }, [load]); - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); // Memoize agent tree structure to avoid recalculating on every render @@ -2552,17 +2823,11 @@ export function Dashboard() { return { childrenByParent, getDescendants }; }, [allSubagents]); - if (error) { - return ( -
-

{t("failedConnect")}

-

{error}

- -
- ); - } + const tabs: Array<{ key: DashboardTab; label: string; icon: typeof BarChart3 }> = [ + { key: "analytics", label: t("tabs.analytics"), icon: BarChart3 }, + { key: "monitor", label: t("tabs.monitor"), icon: Activity }, + { key: "health", label: t("tabs.health"), icon: Server }, + ]; return (
@@ -2591,50 +2856,59 @@ export function Dashboard() {
{/* Tabs */} -
- - - +
+ {tabs.map(({ key, label, icon: Icon }) => ( + + ))}
- + {/* Analytics and Health carry their own Refresh; one per view. */} + {monitorOn && ( + + )}
+ {activeTab !== "health" && } + {activeTab === "monitor" ? (
+ {error && ( +
+ + + {t("failedLoad")}: {error} + + +
+ )} {/* Stats grid - 2 rows of 3 avoids the 6-column squeeze */}
a.status === "working").length : ""} icon={GitBranch} - accentColor="text-violet-700 dark:text-violet-400" + accentColor="text-syntax-keyword" trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined} loading={!stats} /> {isExpanded ? ( @@ -2753,7 +3031,7 @@ export function Dashboard() { empty rather than as a misalignment. */} {!hasChildren && !isSubagent && ( )} {isSubagent && ( - + )}
{hasChildren && isExpanded && ( -
+
{children.map((child) => renderAgentNode(child, depth + 1, childAncestors) )} @@ -2790,7 +3068,7 @@ export function Dashboard() { 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 })} {activeDesc > 0 && ( @@ -2811,9 +3089,8 @@ export function Dashboard() { // was `a.type === "subagent"` with no parentage check, // which surfaced every nested subagent twice: once // indented under its main, and once flush at root level. - const visibleMains = activeAgents - .filter((a) => a.type === "main") - .slice(0, visibleAgentCount); + // Every main agent: the panel scrolls, nothing is dropped. + const visibleMains = activeAgents.filter((a) => a.type === "main"); const renderedInTree = new Set(); for (const m of visibleMains) { const stack: string[] = [m.id]; @@ -2853,84 +3130,7 @@ export function Dashboard() {
{/* Recent activity */} -
-
-

{t("recentActivity")}

- -
- {recentEvents.length === 0 ? ( - - ) : ( -
- {eventCtx.menu && ( - - )} - {recentEvents.slice(0, visibleActivityCount).map((event, i) => ( -
navigate(`/sessions/${event.session_id}`)} - onContextMenu={(e) => eventCtx.open(e, event.session_id)} - > - - - {event.summary || event.event_type} - - {(() => { - // Session label: real name when one exists, else the - // short ID - keeps every activity row attributable. - const sname = sessionsById.get(event.session_id)?.name?.trim() || ""; - const isAuto = /^Session [0-9a-f]{8}$/i.test(sname); - return ( - - {sname && !isAuto ? ( - sname - ) : ( - {event.session_id.slice(0, 8)} - )} - - ); - })()} - {event.tool_name && ( - - {event.tool_name} - - )} - - {timeAgo(event.created_at)} - -
- ))} -
- )} -
+
) : activeTab === "analytics" ? ( diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx index 010a73ea..ce262fe1 100644 --- a/client/src/pages/History.tsx +++ b/client/src/pages/History.tsx @@ -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 }))} - /> - + > + {/* `embedded`: the tab bar names the view, so the panel drops its own title. */} + +
); } diff --git a/client/src/pages/SessionDetail.tsx b/client/src/pages/SessionDetail.tsx index 1733326b..2bbcbd25 100644 --- a/client/src/pages/SessionDetail.tsx +++ b/client/src/pages/SessionDetail.tsx @@ -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([]); const [eventsTotal, setEventsTotal] = useState(0); const [eventsLoadingMore, setEventsLoadingMore] = useState(false); - const [filters, setFilters] = useState(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(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([]); + const isDashboardRun = runs.some((h) => h.sessionId === id && h.status === "running"); const [error, setError] = useState(null); const [expandedAgents, setExpandedAgents] = useState>(() => { return new Set(); }); - const [activeTab, setActiveTab] = useState("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>(() => new Set(["agents"])); @@ -184,8 +218,6 @@ export function SessionDetail() { // Refs let the websocket handler access latest state without re-subscribing. const eventApiParamsRef = useRef | null>(null); const eventsLoadedCountRef = useRef(0); - const eventsRefreshTimerRef = useRef | null>(null); - const loadTimerRef = useRef | 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() {
{/* Header */}
-
-

+

{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} -

+ )}
+ {(() => { + const href = workspaceHref(session, runs); + return href ? ( + + {t("openInWorkspace")} + + ) : null; + })()} -
@@ -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]" }`} >
{t("detail.waitingBanner.title")} {cfg && ( - + {" · "} {t(cfg.labelKey)} )}
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
@@ -711,12 +742,12 @@ export function SessionDetail() { {session.awaiting_input_since && (
)} @@ -817,7 +846,7 @@ export function SessionDetail() { {workflows.length > 0 && (

- + {wfT("runs.sessionTitle")} · {workflows.length}

@@ -830,7 +859,7 @@ export function SessionDetail() { ) : ( <>

- + {t("detail.agents")} · {agents.length}

@@ -896,7 +925,11 @@ export function SessionDetail() { @@ -1074,8 +1107,24 @@ export function SessionDetail() { {visitedTabs.has("timeline") && (