feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers
- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale chunk after a self-update reloads once (vite:preloadError too), any other error shows a Reload panel instead of a blank page. - Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing debounces that froze under a steady event stream; refresh skips hidden tabs and refetches after a WebSocket reconnect. Monitor loads only while shown, with one batched GET /api/agents?session_ids= instead of N+1; a failing endpoint is an inline error, not a blank Dashboard. - NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort and search live in the URL (Dashboard, Sessions, Activity, SessionDetail). - Sessions: tool / workspace / date-range filters, CSV/JSON export (formula-neutralised), focusable row links, Open in workspace (the dead /run links are gone), stale responses dropped, error + Retry. - Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday labels west of UTC (now averages), cache hit rate denominator, billable tokens vs cache reads, exact tool-call counts, "To" date = end of day, invalid ?from= no longer throws, recent list no longer shrinks to 15, stable message keys, transcript switch races. - Light-theme-unreadable bg-black code panels and dark-only DAG fills use tokens; status configs and chart colours use theme tokens; Health tab and other literals translated. - Server (additive): GET /api/agents?session_ids=, GET /api/sessions awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event rows carry session/agent labels.
This commit is contained in:
+79
-11
@@ -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 (
|
||||
<div role="status" className="flex items-center justify-center py-20 text-sm text-fg-muted">
|
||||
{t("common:loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RouteErrorBoundary>
|
||||
<Suspense fallback={<PageFallback />}>{children}</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
return <SessionDetail key={id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<>
|
||||
<SplashScreen />
|
||||
{/* One renderer gives every `title` in the app the designed bubble —
|
||||
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
||||
<GlobalTooltip />
|
||||
{/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */}
|
||||
<Toaster />
|
||||
<ConfirmHost />
|
||||
<BrowserRouter>
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
<Route element={<Layout wsConnected={connected} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
<Route
|
||||
path="sessions/:id"
|
||||
element={
|
||||
<Page>
|
||||
<SessionDetailRoute />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route
|
||||
path="workflows"
|
||||
@@ -107,8 +161,22 @@ export default function App() {
|
||||
element={<Navigate to="/settings/agents/claude-code" replace />}
|
||||
/>
|
||||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="workspace" element={<Workspace />} />
|
||||
<Route path="settings/*" element={<SettingsHub />} />
|
||||
<Route
|
||||
path="workspace"
|
||||
element={
|
||||
<Page>
|
||||
<Workspace />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
<Page>
|
||||
<SettingsHub />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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<true>();
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -176,9 +188,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
onContextMenu={(e) => 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) {
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isMain
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-syntax-keyword/15 text-syntax-keyword"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-sm font-medium text-fg-secondary truncate">
|
||||
{/* 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}
|
||||
</p>
|
||||
{/* 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 ? (
|
||||
<button type="button" onClick={onClick} className={titleClass}>
|
||||
{titleText}
|
||||
</button>
|
||||
) : (
|
||||
<Link to={sessionHref} className={titleClass}>
|
||||
{titleText}
|
||||
</Link>
|
||||
)}
|
||||
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,7 +267,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
{timeAgo(agent.updated_at || agent.started_at)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0 opacity-50">
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0">
|
||||
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
|
||||
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||
</span>
|
||||
@@ -266,4 +282,4 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement>(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" : ""}`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
|
||||
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
{dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X
|
||||
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
|
||||
onClick={clearValue}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{/* A sibling, not a child: a button inside a button is not reachable by keyboard. */}
|
||||
{dateObj && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearValue}
|
||||
aria-label={t("datePicker.clear")}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
@@ -219,18 +251,20 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
||||
{viewDate.toLocaleString(locale, { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
aria-label={t("datePicker.nextMonth")}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -240,8 +274,15 @@ export function DateTimePicker({
|
||||
{/* Calendar Grid */}
|
||||
<div>
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
|
||||
{/* 2026-01-04 is a Sunday: seven local days give the weekday initials. */}
|
||||
{Array.from({ length: 7 }, (_, d) =>
|
||||
new Date(2026, 0, 4 + d).toLocaleDateString(locale, { weekday: "narrow" })
|
||||
).map((day, d) => (
|
||||
<div
|
||||
key={d}
|
||||
aria-hidden
|
||||
className="w-6 text-center text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
@@ -253,10 +294,11 @@ export function DateTimePicker({
|
||||
<div className="pt-3 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-medium">Time</span>
|
||||
<span className="text-[11px] font-medium">{t("datePicker.time")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("datePicker.time")}
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-fg-secondary focus:outline-none focus:border-accent w-[85px] time-input-custom"
|
||||
|
||||
@@ -277,7 +277,7 @@ function SummaryBlock({
|
||||
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
|
||||
return (
|
||||
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
|
||||
<div className="px-3 py-1 border-b border-border bg-black/20">
|
||||
<div className="px-3 py-1 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
|
||||
{t("eventDetail.summary")}
|
||||
</span>
|
||||
@@ -394,8 +394,8 @@ function CodeView({ value }: { value: unknown }) {
|
||||
const text = typeof value === "string" ? value : safeStringify(value);
|
||||
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
|
||||
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
||||
</span>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X, Filter } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -115,6 +115,45 @@ export const EMPTY_FILTERS: EventFiltersValue = {
|
||||
to: "",
|
||||
};
|
||||
|
||||
/** URL key per filter field (short, so a shared link stays readable). */
|
||||
const PARAM_OF: Record<keyof EventFiltersValue, string> = {
|
||||
event_type: "et",
|
||||
tool_name: "tn",
|
||||
agent_id: "ag",
|
||||
session_id: "sid",
|
||||
status: "st",
|
||||
q: "q",
|
||||
from: "from",
|
||||
to: "to",
|
||||
};
|
||||
|
||||
/** Filters read from the URL (lists are comma-joined). */
|
||||
export function filtersFromParams(params: URLSearchParams): EventFiltersValue {
|
||||
const list = (k: string) => (params.get(k) ?? "").split(",").filter(Boolean);
|
||||
return {
|
||||
event_type: list(PARAM_OF.event_type),
|
||||
tool_name: list(PARAM_OF.tool_name),
|
||||
agent_id: list(PARAM_OF.agent_id),
|
||||
session_id: list(PARAM_OF.session_id),
|
||||
status: list(PARAM_OF.status),
|
||||
q: params.get(PARAM_OF.q) ?? "",
|
||||
from: params.get(PARAM_OF.from) ?? "",
|
||||
to: params.get(PARAM_OF.to) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** A copy of `base` with the filter keys rewritten; empty filters are dropped. */
|
||||
export function filtersToParams(f: EventFiltersValue, base: URLSearchParams): URLSearchParams {
|
||||
const next = new URLSearchParams(base);
|
||||
for (const [field, key] of Object.entries(PARAM_OF) as [keyof EventFiltersValue, string][]) {
|
||||
const v = f[field];
|
||||
const str = Array.isArray(v) ? v.join(",") : v;
|
||||
if (str) next.set(key, str);
|
||||
else next.delete(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isEmptyFilters(f: EventFiltersValue): boolean {
|
||||
return (
|
||||
f.event_type.length === 0 &&
|
||||
@@ -180,13 +219,18 @@ export function EventFilters({
|
||||
tool_names: [],
|
||||
});
|
||||
const [searchDraft, setSearchDraft] = useState(value.q);
|
||||
// The debounced search merges into the LATEST value: a chip toggled during
|
||||
// the 300 ms must not be reverted by the value captured at keystroke time.
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Debounce text search - only push up after 300ms of inactivity.
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
|
||||
const latest = valueRef.current;
|
||||
if (searchDraft !== latest.q) onChange({ ...latest, q: searchDraft });
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
@@ -248,6 +292,7 @@ export function EventFilters({
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">→</span>
|
||||
<DateTimePicker
|
||||
endOfDay
|
||||
value={value.to}
|
||||
onChange={(val: string) => onChange({ ...value, to: val })}
|
||||
aria-label={t("eventFilters.to")}
|
||||
@@ -301,11 +346,10 @@ export function EventFilters({
|
||||
onToggle={(item) => toggle("agent_id", item)}
|
||||
/>
|
||||
)}
|
||||
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
|
||||
<ChipGroup
|
||||
{!hideSessionFilter && (
|
||||
<SessionChipGroup
|
||||
label={t("eventFilters.sessionId")}
|
||||
options={sessionOptions.map((s) => s.id)}
|
||||
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
|
||||
known={sessionOptions}
|
||||
selected={value.session_id}
|
||||
onToggle={(item) => toggle("session_id", item)}
|
||||
/>
|
||||
@@ -315,22 +359,98 @@ export function EventFilters({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session filter: searched on the server (`q`, 20 rows), never a list of
|
||||
* every session. Selected ids stay listed (with the label last seen) so they
|
||||
* can be unticked while the search shows other rows.
|
||||
*/
|
||||
function SessionChipGroup({
|
||||
label,
|
||||
known,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
known?: Array<{ id: string; label: string }>;
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const labels = useRef(new Map<string, string>());
|
||||
for (const k of known ?? []) labels.current.set(k.id, k.label);
|
||||
const [open, setOpen] = useState(false);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
const mine = ++seq.current;
|
||||
api.sessions
|
||||
.list({ q: query.trim() || undefined, limit: 20 })
|
||||
.then((res) => {
|
||||
if (mine !== seq.current) return;
|
||||
const next = res.sessions.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.name?.trim() || s.id.slice(0, 8),
|
||||
}));
|
||||
for (const r of next) labels.current.set(r.id, r.label);
|
||||
setRows(next);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
query ? 250 : 0
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, query]);
|
||||
|
||||
const options = [...selected, ...rows.map((r) => r.id).filter((id) => !selected.includes(id))];
|
||||
return (
|
||||
<ChipGroup
|
||||
label={label}
|
||||
options={options}
|
||||
labels={Object.fromEntries(options.map((id) => [id, labels.current.get(id) ?? id]))}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onOpenChange={setOpen}
|
||||
search={
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("eventFilters.searchPlaceholder")}
|
||||
aria-label={label}
|
||||
className="w-full mb-1 bg-surface-2 border border-border rounded px-2 py-1 text-[11px] text-fg-secondary focus:outline-none focus:border-accent"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipGroup({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
labels,
|
||||
onOpenChange,
|
||||
search,
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
labels?: Record<string, string>;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Rendered above the options (a search box). */
|
||||
search?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => onOpenChange?.(open), [open, onOpenChange]);
|
||||
|
||||
// Click-outside dismiss.
|
||||
useEffect(() => {
|
||||
@@ -368,6 +488,7 @@ function ChipGroup({
|
||||
aria-label={label}
|
||||
className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5"
|
||||
>
|
||||
{search}
|
||||
{options.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
|
||||
{t("eventFilters.noOptions")}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function EventFiltersInfo() {
|
||||
<p className="text-[11px] text-fg-secondary mb-2">
|
||||
{t("eventFilters.help.lifecycleDesc")}
|
||||
</p>
|
||||
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
|
||||
<code className="block bg-surface-1 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
|
||||
{t("eventFilters.help.lifecycleFlow")}
|
||||
</code>
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { workspaceHref } from "./SessionRowMenu";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
// The real count: only the first 20 are fetched and listed.
|
||||
const [total, setTotal] = useState(0);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("needsYou.title")}
|
||||
className={`card border-status-warning/40 ${compact ? "px-3 py-2" : "p-4"}`}
|
||||
>
|
||||
<div className={`flex flex-wrap items-center gap-2 ${compact ? "" : "mb-3"}`}>
|
||||
<BellRing className="w-4 h-4 text-status-warning flex-shrink-0" aria-hidden />
|
||||
<h2 className="text-sm font-medium text-fg-primary">
|
||||
{t("needsYou.title")}{" "}
|
||||
<span className="text-fg-muted font-normal">({Math.max(total, sessions.length)})</span>
|
||||
</h2>
|
||||
{compact &&
|
||||
sessions.slice(0, 5).map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-status-warning/10 border border-status-warning/30 text-fg-secondary hover:text-fg-primary truncate max-w-[14rem]"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!compact && (
|
||||
<ul className="divide-y divide-border">
|
||||
{sessions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
|
||||
className="flex items-center gap-3 py-2 px-1 hover:bg-surface-4 rounded"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-status-warning flex-shrink-0" />
|
||||
<span className="text-sm text-fg-secondary truncate flex-1">
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted truncate max-w-[16rem]">{s.cwd}</span>
|
||||
{s.awaiting_input_since && (
|
||||
<span className="text-[11px] text-fg-muted flex-shrink-0">
|
||||
{t("needsYou.since", { ago: timeAgo(s.awaiting_input_since) })}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @file RouteErrorBoundary.tsx
|
||||
* @description Route-level error boundary around the lazily loaded pages. After
|
||||
* a self-update an open tab still asks for the OLD `/assets/<Page>-<hash>.js`,
|
||||
* which the SPA fallback answers with index.html, so the dynamic import
|
||||
* rejects: that case reloads the page once (a sessionStorage guard stops a
|
||||
* reload loop). Any other render error — or a second chunk failure — shows a
|
||||
* themed "Something went wrong — Reload" panel instead of a blank app.
|
||||
* `reloadOnce` is also used by App's `vite:preloadError` listener.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const RELOAD_KEY = "agenthub.chunkReloadAt";
|
||||
/** A second chunk failure within this window means the reload did not help. */
|
||||
const RELOAD_WINDOW_MS = 30_000;
|
||||
|
||||
/** A failed dynamic `import()` (Chrome, Firefox, Safari wordings) or a ChunkLoadError. */
|
||||
export function isChunkLoadError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return (
|
||||
err.name === "ChunkLoadError" ||
|
||||
/dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(
|
||||
err.message
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Reload the page unless this tab already did so in the last 30 s. */
|
||||
export function reloadOnce(reload: () => void = () => window.location.reload()): boolean {
|
||||
try {
|
||||
const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0);
|
||||
if (Date.now() - last < RELOAD_WINDOW_MS) return false;
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
return false; // no sessionStorage: no loop guard, so never auto-reload
|
||||
}
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Injected in tests; defaults to `window.location.reload`. */
|
||||
reload?: () => void;
|
||||
}
|
||||
|
||||
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
|
||||
state = { error: null as Error | null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
if (isChunkLoadError(error)) reloadOnce(this.props.reload);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
const reload = this.props.reload ?? (() => window.location.reload());
|
||||
return (
|
||||
<div role="alert" className="card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{i18n.t("dashboard:errorBoundary.title")}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted max-w-md">{i18n.t("dashboard:errorBoundary.body")}</p>
|
||||
<button type="button" className="btn-primary text-sm" onClick={reload}>
|
||||
{i18n.t("dashboard:errorBoundary.reload")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SessionStats | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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) {
|
||||
</span>
|
||||
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
|
||||
<span className="text-xs text-status-success font-medium flex-shrink-0">
|
||||
{activeAgent.name || "Agent"}
|
||||
{activeAgent.name || t("overview.agent")}
|
||||
</span>
|
||||
{activeAgent.current_tool && (
|
||||
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
|
||||
<span className="text-fg-muted">running</span>
|
||||
<span className="text-fg-muted">{t("overview.running")}</span>
|
||||
<span className="text-status-success">{activeAgent.current_tool}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
{/* Stat tiles */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<StatTile
|
||||
label="Events"
|
||||
label={t("overview.events")}
|
||||
value={stats.total_events.toLocaleString()}
|
||||
hint={
|
||||
eventRate !== null && eventRate > 0
|
||||
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
|
||||
? t("overview.perMin", {
|
||||
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Activity className="w-3 h-3" />}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tool calls"
|
||||
label={t("overview.toolCalls")}
|
||||
value={toolCallCount.toLocaleString()}
|
||||
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
|
||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||
icon={<Wrench className="w-3 h-3" />}
|
||||
tone="violet"
|
||||
/>
|
||||
<StatTile
|
||||
label="Subagents"
|
||||
label={t("overview.subagents")}
|
||||
value={stats.agents.subagent.toLocaleString()}
|
||||
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
|
||||
hint={
|
||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
||||
}
|
||||
icon={<GitBranch className="w-3 h-3" />}
|
||||
tone="cyan"
|
||||
/>
|
||||
<StatTile
|
||||
label="Compactions"
|
||||
label={t("overview.compactions")}
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label="Errors"
|
||||
label={t("overview.errors")}
|
||||
value={stats.error_count.toLocaleString()}
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Duration"
|
||||
label={t("overview.duration")}
|
||||
value={durationLabel}
|
||||
hint={session.status === "active" ? "running" : "completed"}
|
||||
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
|
||||
defaultValue: effectiveSessionStatus(session),
|
||||
})}
|
||||
icon={<Clock className="w-3 h-3" />}
|
||||
tone={session.status === "active" ? "emerald" : "default"}
|
||||
/>
|
||||
@@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
Top tools
|
||||
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||
{t("overview.topTools")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{stats.tools_used.length} total
|
||||
{t("overview.distinctTotal", { count: distinctTools })}
|
||||
</span>
|
||||
</div>
|
||||
{stats.tools_used.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
|
||||
<div className="text-center py-6 text-xs text-fg-muted">
|
||||
{t("overview.noToolCalls")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{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) {
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
|
||||
Subagents
|
||||
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
|
||||
{t("overview.subagents")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{t("overview.runs", { count: totalRuns })}
|
||||
</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-fg-muted">
|
||||
No subagents in this session.
|
||||
{t("overview.noSubagents")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{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 (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||
Token flow
|
||||
{t("overview.tokenFlow")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{t("overview.total", { value: fmt(totalTokens) })}
|
||||
</span>
|
||||
</div>
|
||||
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
||||
</div>
|
||||
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
|
||||
Event mix
|
||||
{t("overview.eventMix")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
||||
@@ -34,11 +51,7 @@ export function SessionRowMenu({
|
||||
{t("menu.details")}
|
||||
</MenuItem>
|
||||
{session.lane_id != null && (
|
||||
<MenuItem
|
||||
onSelect={pick(() =>
|
||||
navigate(`/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`)
|
||||
)}
|
||||
>
|
||||
<MenuItem onSelect={pick(() => navigate(workspaceHref(session)!))}>
|
||||
{t("menu.inWorkspace")}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
@@ -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/<id>, 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);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="card p-5">
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||
{label}
|
||||
@@ -109,6 +113,16 @@ export function StatCard({
|
||||
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="card p-5">{body}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
|
||||
/**
|
||||
* 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"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -240,7 +240,7 @@ describe("AgentCard", () => {
|
||||
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-2");
|
||||
expect(card?.className).toContain("border-l-yellow-500/60");
|
||||
expect(card?.className).toContain("border-l-status-warning/60");
|
||||
});
|
||||
|
||||
it("should not apply active border for completed agents", () => {
|
||||
@@ -267,7 +267,7 @@ describe("AgentCard", () => {
|
||||
);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-yellow-500/60");
|
||||
expect(card?.className).toContain("border-l-status-warning/60");
|
||||
});
|
||||
|
||||
it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @file DateTimePicker.test.tsx
|
||||
* @description An end-bound picker ("To") picks the very end of a day, so an
|
||||
* event at 23:59:30 is still inside the range.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DateTimePicker } from "../DateTimePicker";
|
||||
|
||||
describe("DateTimePicker", () => {
|
||||
it("endOfDay picks 23:59:59.999 of the chosen day", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateTimePicker value="" onChange={onChange} endOfDay aria-label="To" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "To" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
|
||||
expect(onChange.mock.calls[0]![0]).toMatch(/T23:59:59\.999$/);
|
||||
});
|
||||
|
||||
it("a start bound picks midnight", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateTimePicker value="" onChange={onChange} aria-label="From" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "From" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
|
||||
expect(onChange.mock.calls[0]![0]).toMatch(/T00:00$/);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters";
|
||||
import {
|
||||
EventFilters,
|
||||
EMPTY_FILTERS,
|
||||
isEmptyFilters,
|
||||
filtersFromParams,
|
||||
filtersToParams,
|
||||
} from "../EventFilters";
|
||||
import type { EventFiltersValue } from "../EventFilters";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
@@ -82,4 +88,49 @@ describe("EventFilters", () => {
|
||||
fireEvent.click(clear);
|
||||
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
|
||||
});
|
||||
|
||||
it("a debounced search keeps a facet toggled meanwhile (latest value, not the stale one)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search summary/i), {
|
||||
target: { value: "curl" },
|
||||
});
|
||||
// The parent applies a toggle before the 300 ms elapse.
|
||||
const toggled = { ...EMPTY_FILTERS, status: ["error"] };
|
||||
rerender(<EventFilters value={toggled} onChange={onChange} />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ...toggled, q: "curl" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("searches sessions on the server, 20 at a time", async () => {
|
||||
const list = vi
|
||||
.spyOn(api.sessions, "list")
|
||||
.mockResolvedValue({ sessions: [], total: 0, limit: 20, offset: 0 });
|
||||
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} sessionOptions={[]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /session/i }));
|
||||
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: undefined, limit: 20 }));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "abc" } });
|
||||
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: "abc", limit: 20 }));
|
||||
});
|
||||
|
||||
it("round-trips filters through URL params, leaving other keys alone", () => {
|
||||
const value: EventFiltersValue = {
|
||||
...EMPTY_FILTERS,
|
||||
event_type: ["Stop", "PreToolUse"],
|
||||
status: ["error"],
|
||||
q: "curl",
|
||||
to: "2026-01-02T23:59",
|
||||
};
|
||||
const params = filtersToParams(value, new URLSearchParams("tab=timeline"));
|
||||
expect(params.get("tab")).toBe("timeline");
|
||||
expect(filtersFromParams(params)).toEqual(value);
|
||||
expect(filtersToParams(EMPTY_FILTERS, params).toString()).toBe("tab=timeline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file RouteErrorBoundary.test.tsx
|
||||
* @description The route error boundary: a failed lazy chunk (stale assets after
|
||||
* a self-update) reloads the page once; any other error — or a second chunk
|
||||
* failure — shows the themed Reload fallback instead of a blank app.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RouteErrorBoundary, isChunkLoadError, reloadOnce } from "../RouteErrorBoundary";
|
||||
|
||||
function Boom({ error }: { error: Error }): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
reload = vi.fn();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("RouteErrorBoundary", () => {
|
||||
it("recognises dynamic-import failures", () => {
|
||||
expect(
|
||||
isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: /a.js"))
|
||||
).toBe(true);
|
||||
expect(isChunkLoadError(Object.assign(new Error("x"), { name: "ChunkLoadError" }))).toBe(true);
|
||||
expect(isChunkLoadError(new Error("Importing a module script failed."))).toBe(true);
|
||||
expect(isChunkLoadError(new Error("boom"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reloads once on a chunk error, never twice in a row", () => {
|
||||
expect(reloadOnce(reload)).toBe(true);
|
||||
expect(reloadOnce(reload)).toBe(false);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a Reload fallback for an ordinary render error", () => {
|
||||
render(
|
||||
<RouteErrorBoundary reload={reload}>
|
||||
<Boom error={new Error("boom")} />
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/something went wrong/i);
|
||||
expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads on a chunk error the first time", () => {
|
||||
render(
|
||||
<RouteErrorBoundary reload={reload}>
|
||||
<Boom error={new TypeError("Failed to fetch dynamically imported module: /x.js")} />
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe("AgentStatusBadge", () => {
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
|
||||
expect(container.querySelector(".bg-status-warning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("SessionStatusBadge", () => {
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const dot = container.querySelector(".animate-pulse-dot");
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
|
||||
expect(container.querySelector(".bg-status-warning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,10 +135,14 @@ describe("awaiting-reason suffix", () => {
|
||||
});
|
||||
|
||||
it("marks urgent reasons with the hotter amber tint", () => {
|
||||
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
|
||||
expect(container.querySelector(".text-status-warning")).toBeInTheDocument();
|
||||
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
|
||||
expect(calm.querySelector(".text-status-warning")).not.toBeInTheDocument();
|
||||
render(<AgentStatusBadge status="waiting" reason="notification" />);
|
||||
expect(screen.getByText("Needs input").closest("span.rounded-full")).toHaveClass(
|
||||
"text-status-warning"
|
||||
);
|
||||
render(<AgentStatusBadge status="waiting" reason="stop" />);
|
||||
expect(screen.getByText("Turn done").closest("span.rounded-full")).not.toHaveClass(
|
||||
"text-status-warning"
|
||||
);
|
||||
});
|
||||
|
||||
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { Check, Copy, FileCode } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight";
|
||||
|
||||
@@ -122,14 +124,13 @@ export function CodeBlock({
|
||||
const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]);
|
||||
const totalLines = lineTokens.length;
|
||||
const gutter = showLineNumbers ?? totalLines >= 4;
|
||||
const { t } = useTranslation("sessions");
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
// copyText reports a failure itself (non-secure origin, no permission).
|
||||
if (await copyText(code, true)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard may be unavailable in some contexts - fail silently.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +182,7 @@ export function CodeBlock({
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{totalLines > 1 && (
|
||||
<span className="text-fg-muted font-mono">
|
||||
{totalLines} {totalLines === 1 ? "line" : "lines"}
|
||||
{t("conversation.lines", { count: totalLines })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -190,15 +191,15 @@ export function CodeBlock({
|
||||
className={`inline-flex items-center gap-1 transition-colors ${
|
||||
copied ? "text-status-success" : "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
aria-label="Copy code"
|
||||
aria-label={t("conversation.copyCode")}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" /> Copied
|
||||
<Check className="w-3 h-3" /> {t("common:copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" /> Copy
|
||||
<Copy className="w-3 h-3" /> {t("conversation.copy")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -54,11 +54,12 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { ChevronDown, Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||
@@ -77,6 +78,7 @@ interface ConversationViewProps {
|
||||
}
|
||||
|
||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -99,6 +101,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// manual refresh), we queue exactly one re-fetch so events that landed
|
||||
// during the in-flight request aren't silently dropped.
|
||||
const pendingFetchRef = useRef(false);
|
||||
// Bumped whenever the session or the selected transcript changes: a tail
|
||||
// fetch started for the previous transcript must not append to the new one.
|
||||
const genRef = useRef(0);
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -117,7 +122,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(loadTranscripts, TRANSCRIPTS_REFRESH_MS);
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
@@ -134,6 +141,9 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// Initial load: fetch the latest N messages
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
genRef.current++;
|
||||
lastLineRef.current = 0;
|
||||
firstLineRef.current = 0;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -152,7 +162,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
firstLineRef.current = result.first_line;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load transcript");
|
||||
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -184,12 +194,14 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
pendingFetchRef.current = false;
|
||||
|
||||
const wasBootstrap = lastLineRef.current === 0;
|
||||
const gen = genRef.current;
|
||||
try {
|
||||
const result = await api.sessions.transcript(sessionId, {
|
||||
agent_id: selectedTranscript || undefined,
|
||||
...(wasBootstrap ? {} : { after: lastLineRef.current }),
|
||||
limit: 50,
|
||||
});
|
||||
if (gen !== genRef.current) return; // another transcript is shown now
|
||||
if (result.messages.length === 0) return;
|
||||
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -393,31 +405,31 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
<select
|
||||
value={selectedTranscript || ""}
|
||||
onChange={(e) => setSelectedTranscript(e.target.value || null)}
|
||||
className="appearance-none bg-surface-2 border border-surface-3 rounded-lg px-3 py-1.5 pr-8 text-sm text-fg-secondary focus:outline-none focus:border-violet-500/50 hover:border-violet-500/30 cursor-pointer transition-colors"
|
||||
aria-label={t("conversation.transcript")}
|
||||
className="select py-1.5"
|
||||
>
|
||||
{transcripts.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{transcripts.map((tr) => (
|
||||
<option key={tr.id} value={tr.id}>
|
||||
{tr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 border border-surface-3 rounded-md px-2 py-1">
|
||||
<MessagesSquare className="w-3 h-3" />
|
||||
{total} message{total !== 1 ? "s" : ""}
|
||||
{t("conversation.messages", { count: total })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={refreshing || loading}
|
||||
title="Refresh conversation"
|
||||
aria-label="Refresh conversation"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-violet-500/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t("conversation.refresh")}
|
||||
aria-label={t("conversation.refresh")}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-fg-secondary hover:text-fg-primary bg-surface-2 border border-surface-3 hover:border-accent/30 rounded-md px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -440,30 +452,26 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">Loading history...</span>
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">↑ Scroll up for older messages</span>
|
||||
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
Loading conversation...
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">No conversation records found.</p>
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
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 (<code className="text-fg-secondary">cleanupPeriodDays</code>, 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")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -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"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
New messages
|
||||
{t("conversation.newMessages")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
|
||||
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 {
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Persisted output"
|
||||
borderClass="border-violet-500/20"
|
||||
bgClass="bg-violet-500/5"
|
||||
textClass="text-violet-700/80 dark:text-violet-300/80"
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-keyword/70 flex-shrink-0" />}
|
||||
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({
|
||||
<div className={`rounded-lg border ${borderClass} ${bgClass} overflow-hidden`}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
@@ -317,206 +308,227 @@ function CollapsibleBlock({
|
||||
);
|
||||
}
|
||||
|
||||
/** A key per message that survives prepend (older history) and append (live
|
||||
* tail): built from the message itself, with an occurrence counter for twins.
|
||||
* The index was the key before, so expanding a block and then loading older
|
||||
* messages moved the expansion onto another message. */
|
||||
export function messageKeys(messages: TranscriptMessage[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return messages.map((m) => {
|
||||
const first = m.content[0];
|
||||
const sig = first
|
||||
? `${first.type}:${first.id ?? (first.text ?? "").slice(0, 48)}`
|
||||
: (m.title ?? "");
|
||||
const base = `${m.timestamp ?? ""}|${m.type}|${m.sender ?? ""}|${sig}`;
|
||||
const n = seen.get(base) ?? 0;
|
||||
seen.set(base, n + 1);
|
||||
return n ? `${base}#${n}` : base;
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading }: MessageListProps) {
|
||||
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => new Set());
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
Loading conversation...
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-fg-muted text-sm">No conversation records found.</div>
|
||||
);
|
||||
return <div className="text-center py-12 text-fg-muted text-sm">{t("conversation.empty")}</div>;
|
||||
}
|
||||
|
||||
const toolResultMap = buildToolResultMap(messages);
|
||||
|
||||
// Track which user messages are pure tool_result (no text) - we merge those into the preceding assistant message
|
||||
const userMsgHasText = useMemo(() => {
|
||||
const map = new Map<number, boolean>();
|
||||
messages.forEach((msg, idx) => {
|
||||
if (msg.type !== "user") return;
|
||||
const hasText = msg.content.some((c) => c.type === "text");
|
||||
map.set(idx, hasText);
|
||||
});
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => {
|
||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||
// not as a user/assistant row.
|
||||
if (msg.type === "session_event") {
|
||||
return <SessionEventRow key={idx} title={msg.title} timestamp={msg.timestamp} />;
|
||||
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
|
||||
}
|
||||
|
||||
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
|
||||
if (msg.type === "user" && !userMsgHasText.get(idx)) {
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
|
||||
>
|
||||
<SenderIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Message body */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
{msg.model && (
|
||||
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{formatModelName(msg.model)}
|
||||
</span>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-orange-700/70 dark:text-orange-300/70">
|
||||
↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-fg-muted ml-auto font-mono">
|
||||
{formatLocalTime(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(block.text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Task Notification"
|
||||
borderClass="border-cyan-500/20"
|
||||
bgClass="bg-cyan-500/5"
|
||||
textClass="text-cyan-700/80 dark:text-cyan-400/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-blue-500/60 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-blue-600/20"
|
||||
bgClass="bg-blue-600/5"
|
||||
textClass="text-blue-500/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div key={bIdx} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={bIdx} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(block.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
const thinkKey = idx * 100 + bIdx;
|
||||
const isExpanded = expandedThinking.has(thinkKey);
|
||||
return (
|
||||
<div
|
||||
key={bIdx}
|
||||
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedThinking((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(thinkKey)) next.delete(thinkKey);
|
||||
else next.add(thinkKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
|
||||
<span className="text-xs text-status-warning/90 font-medium">Thinking</span>
|
||||
{!isExpanded && (
|
||||
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
|
||||
{block.text.length.toLocaleString()} chars
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
|
||||
<MarkdownContent text={block.text} dense />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null;
|
||||
return <ToolCallBlock key={bIdx} toolUse={block} toolResult={matchedResult} />;
|
||||
}
|
||||
|
||||
// tool_result blocks rendered inside ToolCallBlock, skip standalone
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<typeof buildToolResultMap>;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expandedThinking, setExpandedThinking] = useState<Set<number>>(() => 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 (
|
||||
<div
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
|
||||
className={`relative flex gap-3 rounded-xl px-3 py-2.5 hover:bg-surface-2/30 transition-colors before:absolute before:left-0 before:top-3 before:bottom-3 before:w-0.5 before:rounded-full before:opacity-60 ${style.accentBar}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-0.5 shadow-sm ${style.avatarRing}`}
|
||||
>
|
||||
<SenderIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Message body */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold tracking-wide ${style.headerText}`}>
|
||||
{t(`conversation.sender.${sender}`)}
|
||||
</span>
|
||||
{msg.model && (
|
||||
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{formatModelName(msg.model)}
|
||||
</span>
|
||||
)}
|
||||
{msg.usage && (
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-fg-muted ml-auto font-mono">
|
||||
{formatLocalTime(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(block.text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
|
||||
}
|
||||
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 (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||
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 (
|
||||
<div key={bIdx} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={bIdx} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(block.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
const thinkKey = bIdx;
|
||||
const isExpanded = expandedThinking.has(thinkKey);
|
||||
return (
|
||||
<div
|
||||
key={bIdx}
|
||||
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() =>
|
||||
setExpandedThinking((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(thinkKey)) next.delete(thinkKey);
|
||||
else next.add(thinkKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
|
||||
<span className="text-xs text-status-warning/90 font-medium">
|
||||
{t("conversation.thinking")}
|
||||
</span>
|
||||
{!isExpanded && (
|
||||
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
|
||||
{t("conversation.chars", {
|
||||
count: block.text.length,
|
||||
formatted: block.text.length.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
|
||||
<MarkdownContent text={block.text} dense />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null;
|
||||
return <ToolCallBlock key={bIdx} toolUse={block} toolResult={matchedResult} />;
|
||||
}
|
||||
|
||||
// tool_result blocks rendered inside ToolCallBlock, skip standalone
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
<CodeBlock
|
||||
code={String((input as { _truncated: string })._truncated)}
|
||||
lang="plain"
|
||||
label="Input (truncated)"
|
||||
label={tt("inputTruncated")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +134,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
if (tool === "bash" && typeof obj.command === "string") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CodeBlock code={obj.command} lang="bash" label="Command" />
|
||||
<CodeBlock code={obj.command} lang="bash" label={tt("command")} />
|
||||
{typeof obj.description === "string" && (
|
||||
<p className="text-xs text-fg-muted italic px-1">{obj.description}</p>
|
||||
)}
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
<FileText className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||
<span className="font-mono">{obj.file_path}</span>
|
||||
{obj.replace_all === true && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
|
||||
replace all
|
||||
{tt("replaceAll")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof obj.old_string === "string" && (
|
||||
<CodeBlock code={obj.old_string} lang={lang} label="Removed" tone="danger" />
|
||||
<CodeBlock code={obj.old_string} lang={lang} label={tt("removed")} tone="danger" />
|
||||
)}
|
||||
{typeof obj.new_string === "string" && (
|
||||
<CodeBlock code={obj.new_string} lang={lang} label="Added" tone="success" />
|
||||
<CodeBlock code={obj.new_string} lang={lang} label={tt("added")} tone="success" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -178,7 +182,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
if (tool === "read" && typeof obj.file_path === "string") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
||||
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
|
||||
<FileText className="w-3.5 h-3.5 text-syntax-builtin flex-shrink-0" />
|
||||
<span className="font-mono break-all">{obj.file_path}</span>
|
||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
|
||||
@@ -196,16 +200,16 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||
Pattern
|
||||
{tt("pattern")}
|
||||
</span>
|
||||
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
<code className="font-mono text-syntax-property bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{obj.pattern}
|
||||
</code>
|
||||
</div>
|
||||
{typeof obj.path === "string" && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||
Path
|
||||
{tt("path")}
|
||||
</span>
|
||||
<code className="font-mono text-fg-secondary">{obj.path}</code>
|
||||
</div>
|
||||
@@ -213,7 +217,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
{typeof obj.glob === "string" && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||
Glob
|
||||
{tt("glob")}
|
||||
</span>
|
||||
<code className="font-mono text-fg-secondary">{obj.glob}</code>
|
||||
</div>
|
||||
@@ -223,17 +227,18 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
}
|
||||
|
||||
// Default: pretty JSON
|
||||
return <CodeBlock code={JSON.stringify(obj, null, 2)} lang="json" label="Input" />;
|
||||
return <CodeBlock code={JSON.stringify(obj, null, 2)} lang="json" label={tt("input")} />;
|
||||
}
|
||||
|
||||
/** Render the result pane: detect diff/json/text. */
|
||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
const text = toolResult.output ?? "";
|
||||
if (text.length === 0) return <div className="text-xs text-fg-muted italic px-1">(empty)</div>;
|
||||
if (text.length === 0)
|
||||
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
||||
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-surface-3/40 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -297,16 +303,16 @@ export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) {
|
||||
{isError ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-danger bg-status-danger/15 border border-status-danger/20 rounded px-1.5 py-0.5">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
error
|
||||
{tt("statusError")}
|
||||
</span>
|
||||
) : hasResult ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-status-success/80 bg-status-success/10 border border-status-success/20 rounded px-1.5 py-0.5">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
ok
|
||||
{tt("statusOk")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wider font-mono">
|
||||
pending
|
||||
{tt("statusPending")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file MessageList.keys.test.ts
|
||||
* @description Message keys survive older history being prepended (the index
|
||||
* key moved expansion state onto another message).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { messageKeys } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (ts: string, text: string): TranscriptMessage => ({
|
||||
type: "assistant",
|
||||
timestamp: ts,
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
|
||||
describe("messageKeys", () => {
|
||||
it("keeps each message's key when older messages are prepended", () => {
|
||||
const tail = [msg("2026-01-01T00:00:02Z", "b"), msg("2026-01-01T00:00:03Z", "c")];
|
||||
const before = messageKeys(tail);
|
||||
const after = messageKeys([msg("2026-01-01T00:00:01Z", "a"), ...tail]);
|
||||
expect(after.slice(1)).toEqual(before);
|
||||
});
|
||||
|
||||
it("gives identical twins distinct keys", () => {
|
||||
const m = msg("2026-01-01T00:00:01Z", "same");
|
||||
const keys = messageKeys([m, { ...m }]);
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -137,8 +137,8 @@ export function CopyButton({ text }: { text: string }) {
|
||||
|
||||
export function Terminal({ command, description }: { command: string; description?: string }) {
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide">terminal</span>
|
||||
<CopyButton text={command} />
|
||||
</div>
|
||||
@@ -207,8 +207,8 @@ function OutputBlock({
|
||||
}) {
|
||||
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
@@ -234,9 +234,9 @@ export function LineNumberedCode({
|
||||
}) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
|
||||
<CopyButton text={text} />
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@ export function LineNumberedCode({
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 text-right text-fg-muted select-none bg-black/40 border-r border-border align-top">
|
||||
<td className="px-2 text-right text-fg-muted select-none bg-surface-1 border-r border-border align-top">
|
||||
{i + startLine}
|
||||
</td>
|
||||
<td className="px-3 text-fg-secondary whitespace-pre-wrap break-words">{line}</td>
|
||||
@@ -274,7 +274,7 @@ export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
|
||||
return <p className="text-[11px] text-fg-muted italic">no diff</p>;
|
||||
}
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="overflow-auto max-h-96">
|
||||
{hunks.map((hunk, i) => (
|
||||
<HunkView key={i} hunk={hunk} />
|
||||
|
||||
@@ -58,8 +58,11 @@
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { CompactionImpactData } from "../../lib/types";
|
||||
import { max, axisBottom, axisLeft, scaleBand, scaleLinear, select, type Selection } from "d3";
|
||||
|
||||
// Named imports so Vite tree-shakes d3 down to what these charts use.
|
||||
const d3 = { axisBottom, axisLeft, max, scaleBand, scaleLinear, select };
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,11 +125,11 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("y1", "0%")
|
||||
.attr("x2", "0%")
|
||||
.attr("y2", "100%");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", "rgb(var(--accent))");
|
||||
grad
|
||||
.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", "#3730a3")
|
||||
.attr("stop-color", "rgb(var(--accent-solid))")
|
||||
.attr("stop-opacity", 0.7);
|
||||
|
||||
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
||||
@@ -212,7 +215,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
const barH = innerH - by;
|
||||
|
||||
const bg = g.append("g");
|
||||
let bar: d3.Selection<SVGRectElement, unknown, null, undefined> | null = null;
|
||||
let bar: Selection<SVGRectElement, unknown, null, undefined> | null = null;
|
||||
|
||||
if (d.sessions > 0) {
|
||||
bar = bg
|
||||
@@ -254,7 +257,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("fill", "transparent")
|
||||
.style("cursor", "pointer")
|
||||
.on("mouseenter", (event: MouseEvent) => {
|
||||
if (bar) bar.attr("fill", "#a5b4fc");
|
||||
if (bar) bar.attr("fill", "rgb(var(--accent-hover))");
|
||||
opts.onHover(event, d);
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
|
||||
@@ -305,6 +308,20 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
|
||||
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
||||
|
||||
// Redraw on resize (the histogram reads its container's width when drawn),
|
||||
// like the Session Complexity Scatter does.
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = svgRef.current?.parentElement;
|
||||
if (!el) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w) setWidth(Math.round(w));
|
||||
});
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [hasData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !hasData) return;
|
||||
const histo = toHistogram(data.perSession);
|
||||
@@ -324,7 +341,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
|
||||
onLeave: () => setTip(null),
|
||||
});
|
||||
}, [data, hasData, t]);
|
||||
}, [data, hasData, t, width]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
|
||||
@@ -55,7 +55,12 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ErrorPropagationData } from "../../lib/types";
|
||||
|
||||
const DEPTH_COLORS = ["#ef4444", "#f97316", "#eab308", "#a855f7"];
|
||||
const DEPTH_COLORS = [
|
||||
"rgb(var(--status-danger))",
|
||||
"rgb(var(--syn-number))",
|
||||
"rgb(var(--status-warning))",
|
||||
"rgb(var(--syn-keyword))",
|
||||
];
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -240,7 +245,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
stroke="rgb(var(--status-warning))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import type { PipelineStats } from "../../lib/types";
|
||||
|
||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||
@@ -26,7 +26,12 @@ function pct(n: number, total: number): number {
|
||||
* Per-node pass statistics for the selected pipeline template.
|
||||
* @returns The panel element, or an empty-state hint when nothing was recorded.
|
||||
*/
|
||||
export function LanePipelinePanel() {
|
||||
export function LanePipelinePanel({
|
||||
onAvailability,
|
||||
}: {
|
||||
/** Told whether any pipeline template is loaded (pipelines are dormant by default). */
|
||||
onAvailability?: (hasPipelines: boolean) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<PipelineStats | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -46,18 +51,14 @@ export function LanePipelinePanel() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Stages move as agents declare them; debounce so a burst costs one fetch.
|
||||
// Stages move as agents declare them; throttled (not debounced) so a burst
|
||||
// costs one fetch and a steady stream still refreshes every 3 s.
|
||||
useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
|
||||
|
||||
const hasPipelines = !!data && data.pipelines.length > 0;
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const unsub = eventBus.subscribe(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void refresh(), 3000);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
if (data) onAvailability?.(hasPipelines);
|
||||
}, [data, hasPipelines, onAvailability]);
|
||||
|
||||
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
||||
if (!data) return null;
|
||||
@@ -71,7 +72,7 @@ export function LanePipelinePanel() {
|
||||
<select
|
||||
data-testid="pipeline-stats-lane-picker"
|
||||
aria-label={t("lanes.laneLabel")}
|
||||
className="max-w-[14rem] truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
|
||||
className="select max-w-[14rem] truncate py-0.5 text-xs"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
@@ -113,7 +114,7 @@ export function LanePipelinePanel() {
|
||||
<select
|
||||
data-testid="pipeline-stats-picker"
|
||||
aria-label={t("lanes.pipelineLabel")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
|
||||
className="select w-auto py-0.5 text-xs"
|
||||
value={selected.id}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
>
|
||||
|
||||
@@ -47,8 +47,11 @@
|
||||
|
||||
import { useRef, useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { OrchestrationData } from "../../lib/types";
|
||||
import { extent, scaleLinear, select } from "d3";
|
||||
|
||||
// Named imports so Vite tree-shakes d3 down to what these charts use.
|
||||
const d3 = { extent, scaleLinear, select };
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,52 +107,45 @@ const BADGE_H = 14;
|
||||
|
||||
// Layer labels are now computed via i18n inside the component
|
||||
|
||||
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> = {
|
||||
completed: { fill: "#052e16", stroke: "#16a34a", text: "#4ade80" },
|
||||
error: { fill: "#1f0808", stroke: "#dc2626", text: "#f87171" },
|
||||
abandoned: { fill: "#1c1a04", stroke: "#ca8a04", text: "#facc15" },
|
||||
// Every colour is a theme token (or a mix of one over the card surface), so
|
||||
// the DAG reads in light themes too — it used to be dark-only hex.
|
||||
const tok = (v: string) => `rgb(var(${v}))`;
|
||||
const mix = (v: string, pct: number) =>
|
||||
`color-mix(in srgb, rgb(var(${v})) ${pct}%, rgb(var(--surface-1)))`;
|
||||
|
||||
const KIND_VAR: Record<DAGNode["kind"], string> = {
|
||||
session: "--accent",
|
||||
main: "--syn-builtin",
|
||||
subagent: "--status-success",
|
||||
nested: "--syn-property",
|
||||
outcome: "--accent",
|
||||
};
|
||||
const OUTCOME_VAR: Record<string, string> = {
|
||||
completed: "--status-success",
|
||||
error: "--status-danger",
|
||||
abandoned: "--status-warning",
|
||||
};
|
||||
|
||||
const KIND_GRADIENTS: Record<
|
||||
DAGNode["kind"],
|
||||
{ id: string; stops: Array<{ offset: string; color: string }> }
|
||||
> = {
|
||||
session: {
|
||||
id: "grad-session",
|
||||
stops: [
|
||||
{ offset: "0%", color: "#312e81" },
|
||||
{ offset: "100%", color: "#4338ca" },
|
||||
],
|
||||
},
|
||||
main: {
|
||||
id: "grad-main",
|
||||
stops: [
|
||||
{ offset: "0%", color: "#1e3a5f" },
|
||||
{ offset: "100%", color: "#1d4ed8" },
|
||||
],
|
||||
},
|
||||
subagent: {
|
||||
id: "grad-subagent",
|
||||
stops: [
|
||||
{ offset: "0%", color: "#052e16" },
|
||||
{ offset: "100%", color: "#166534" },
|
||||
],
|
||||
},
|
||||
nested: {
|
||||
id: "grad-nested",
|
||||
stops: [
|
||||
{ offset: "0%", color: "#134e4a" },
|
||||
{ offset: "100%", color: "#0f766e" },
|
||||
],
|
||||
},
|
||||
outcome: {
|
||||
id: "grad-outcome",
|
||||
stops: [
|
||||
{ offset: "0%", color: "#1e1b4b" },
|
||||
{ offset: "100%", color: "#4338ca" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const OUTCOME_COLORS: Record<string, { fill: string; stroke: string; text: string }> =
|
||||
Object.fromEntries(
|
||||
Object.entries(OUTCOME_VAR).map(([k, v]) => [
|
||||
k,
|
||||
{ fill: mix(v, 15), stroke: tok(v), text: tok(v) },
|
||||
])
|
||||
);
|
||||
|
||||
const KIND_GRADIENTS = Object.fromEntries(
|
||||
(Object.keys(KIND_VAR) as DAGNode["kind"][]).map((kind) => [
|
||||
kind,
|
||||
{
|
||||
id: `grad-${kind}`,
|
||||
stops: [
|
||||
{ offset: "0%", color: mix(KIND_VAR[kind], 10) },
|
||||
{ offset: "100%", color: mix(KIND_VAR[kind], 25) },
|
||||
],
|
||||
},
|
||||
])
|
||||
) as Record<DAGNode["kind"], { id: string; stops: Array<{ offset: string; color: string }> }>;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -168,7 +164,13 @@ function successRate(completed: number, total: number): string {
|
||||
}
|
||||
|
||||
function outcomeColorSet(status: string) {
|
||||
return OUTCOME_COLORS[status] ?? { fill: "#1a1a28", stroke: "#363650", text: "#9ca3af" };
|
||||
return (
|
||||
OUTCOME_COLORS[status] ?? {
|
||||
fill: tok("--surface-2"),
|
||||
stroke: tok("--border"),
|
||||
text: tok("--fg-muted"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout builder ────────────────────────────────────────────────────────────
|
||||
@@ -641,7 +643,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.append("path")
|
||||
.attr("d", path)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#6366f1")
|
||||
.attr("stroke", tok("--accent"))
|
||||
.attr("stroke-width", stroke + 2)
|
||||
.attr("stroke-opacity", isZero ? 0 : 0.08)
|
||||
.attr("filter", "url(#edge-glow)");
|
||||
@@ -651,7 +653,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.append("path")
|
||||
.attr("d", path)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
|
||||
.attr("stroke", isZero ? "rgb(var(--border-light))" : tok("--accent"))
|
||||
.attr("stroke-width", isZero ? 1 : stroke)
|
||||
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
|
||||
.attr("stroke-linecap", "round");
|
||||
@@ -698,7 +700,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("height", (d) => d.height + 6)
|
||||
.attr("rx", NODE_RX + 3)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#6366f1")
|
||||
.attr("stroke", tok("--accent"))
|
||||
.attr("stroke-width", 2)
|
||||
.attr("filter", "url(#glow)")
|
||||
.attr("opacity", 0.8);
|
||||
@@ -717,7 +719,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
return `url(#${KIND_GRADIENTS[d.kind].id})`;
|
||||
})
|
||||
.attr("stroke", (d) => {
|
||||
if (d.id === selectedNode) return "#6366f1";
|
||||
if (d.id === selectedNode) return tok("--accent");
|
||||
if (d.kind === "outcome" && d.meta?.status) {
|
||||
return outcomeColorSet(d.meta.status).stroke;
|
||||
}
|
||||
@@ -831,18 +833,22 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
{t("orchestration.legend")}
|
||||
</span>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<div key={item.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
|
||||
style={{ background: item.color, border: `1px solid ${item.border}` }}
|
||||
/>
|
||||
<span className="text-[11px] text-fg-muted">{item.label}</span>
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
{t(`orchestration.legendItems.${item.key}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<span
|
||||
className="inline-block h-[2px] w-8 rounded flex-shrink-0"
|
||||
style={{ background: "linear-gradient(to right, #312e81, #4f46e5)" }}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${mix("--accent", 30)}, ${tok("--accent")})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] text-fg-muted">{t("orchestration.edgeWeight")}</span>
|
||||
</div>
|
||||
@@ -1014,51 +1020,16 @@ function describeNode(node: DAGNode, t: TFn): { layer: string; description: stri
|
||||
// ── Utility functions ─────────────────────────────────────────────────────────
|
||||
|
||||
function borderColorForKind(kind: DAGNode["kind"]): string {
|
||||
switch (kind) {
|
||||
case "session":
|
||||
return "#6366f1";
|
||||
case "main":
|
||||
return "#3b82f6";
|
||||
case "subagent":
|
||||
return "#22c55e";
|
||||
case "nested":
|
||||
return "#14b8a6";
|
||||
case "outcome":
|
||||
return "#6366f1";
|
||||
}
|
||||
return tok(KIND_VAR[kind]);
|
||||
}
|
||||
|
||||
function textColorForKind(kind: DAGNode["kind"]): string {
|
||||
switch (kind) {
|
||||
case "session":
|
||||
return "#a5b4fc";
|
||||
case "main":
|
||||
return "#93c5fd";
|
||||
case "subagent":
|
||||
return "#86efac";
|
||||
case "nested":
|
||||
return "#5eead4";
|
||||
case "outcome":
|
||||
return "#c4b5fd";
|
||||
}
|
||||
return tok(KIND_VAR[kind]);
|
||||
}
|
||||
|
||||
function badgeBgForKind(kind: DAGNode["kind"], status?: string): string {
|
||||
if (kind === "outcome" && status) {
|
||||
return outcomeColorSet(status).stroke + "33";
|
||||
}
|
||||
switch (kind) {
|
||||
case "session":
|
||||
return "rgba(99,102,241,0.25)";
|
||||
case "main":
|
||||
return "rgba(59,130,246,0.25)";
|
||||
case "subagent":
|
||||
return "rgba(34,197,94,0.25)";
|
||||
case "nested":
|
||||
return "rgba(20,184,166,0.25)";
|
||||
case "outcome":
|
||||
return "rgba(99,102,241,0.25)";
|
||||
}
|
||||
if (kind === "outcome" && status) return mix(OUTCOME_VAR[status] ?? "--border", 25);
|
||||
return mix(KIND_VAR[kind], 25);
|
||||
}
|
||||
|
||||
function fmtCount(n: number): string {
|
||||
@@ -1070,11 +1041,11 @@ function fmtCount(n: number): string {
|
||||
// ── Legend data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{ label: "Sessions", color: "#312e81", border: "#6366f1" },
|
||||
{ label: "Main Agent", color: "#1e3a5f", border: "#3b82f6" },
|
||||
{ label: "Subagent Types", color: "#052e16", border: "#22c55e" },
|
||||
{ label: "Compactions", color: "#134e4a", border: "#14b8a6" },
|
||||
{ label: "Completed", color: "#052e16", border: "#16a34a" },
|
||||
{ label: "Error", color: "#1f0808", border: "#dc2626" },
|
||||
{ label: "Abandoned", color: "#1c1a04", border: "#ca8a04" },
|
||||
];
|
||||
{ key: "sessions", v: "--accent" },
|
||||
{ key: "mainAgent", v: "--syn-builtin" },
|
||||
{ key: "subagentTypes", v: "--status-success" },
|
||||
{ key: "compactions", v: "--syn-property" },
|
||||
{ key: "completed", v: "--status-success" },
|
||||
{ key: "error", v: "--status-danger" },
|
||||
{ key: "abandoned", v: "--status-warning" },
|
||||
].map((i) => ({ ...i, color: mix(i.v, 20), border: tok(i.v) }));
|
||||
|
||||
@@ -54,9 +54,12 @@
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as d3 from "d3";
|
||||
import type { SessionComplexityItem } from "../../lib/types";
|
||||
import { formatModelName } from "../../lib/format";
|
||||
import { max, axisBottom, axisLeft, format, scaleLinear, scaleSqrt, select } from "d3";
|
||||
|
||||
// Named imports so Vite tree-shakes d3 down to what these charts use.
|
||||
const d3 = { axisBottom, axisLeft, format, max, scaleLinear, scaleSqrt, select };
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,14 +68,14 @@ const MIN_BUBBLE_R = 4;
|
||||
const MAX_BUBBLE_R = 32;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
completed: "#22c55e",
|
||||
error: "#ef4444",
|
||||
active: "#6366f1",
|
||||
abandoned: "#eab308",
|
||||
completed: "rgb(var(--status-success))",
|
||||
error: "rgb(var(--status-danger))",
|
||||
active: "rgb(var(--accent))",
|
||||
abandoned: "rgb(var(--status-warning))",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
return STATUS_COLOR[status] ?? "#6b7280";
|
||||
return STATUS_COLOR[status] ?? "rgb(var(--fg-muted))";
|
||||
}
|
||||
|
||||
// ── Duration formatting ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,8 +56,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
|
||||
import { formatDateTime, formatDuration, formatModelName } from "../../lib/format";
|
||||
import type {
|
||||
SessionDrillIn as SessionDrillInData,
|
||||
DashboardEvent,
|
||||
@@ -157,14 +159,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
const { t } = useTranslation(["workflows", "common"]);
|
||||
const indentPx = depth * 20;
|
||||
const isMain = node.type === "main";
|
||||
const dur = node.ended_at
|
||||
? formatMs(
|
||||
Math.max(
|
||||
0,
|
||||
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
|
||||
)
|
||||
)
|
||||
: t("common:running");
|
||||
const dur = node.ended_at ? formatDuration(node.started_at, node.ended_at) : t("common:running");
|
||||
const sc = statusColor(node.status);
|
||||
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
|
||||
|
||||
@@ -451,14 +446,27 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
className="btn-ghost text-xs px-2 py-1"
|
||||
>
|
||||
{t("drillIn.openSession")}
|
||||
</Link>
|
||||
{session.lane_id != null && (
|
||||
<Link to={workspaceHref(session)!} className="btn-ghost text-xs px-2 py-1">
|
||||
{t("drillIn.openInWorkspace")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabBar active={activeTab} onChange={onTabChange} />
|
||||
</div>
|
||||
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allLoaded, setAllLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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) {
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
|
||||
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
|
||||
"focus-within:ring-2 focus-within:ring-accent/40",
|
||||
open ? "border-accent/40" : "border-border",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more - only show when not filtering client-side */}
|
||||
{!search.trim() && hasMore && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import type { SkillUsage } from "../../lib/types";
|
||||
|
||||
export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
@@ -33,17 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const unsub = eventBus.subscribe(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void refresh(), 3000);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
// Throttled (not debounced): a steady event stream still refreshes every 3 s.
|
||||
useLiveRefresh(() => void refresh(), {
|
||||
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
||||
});
|
||||
|
||||
if (error) return <p className="text-xs text-status-danger">{error}</p>;
|
||||
if (!data) return null;
|
||||
|
||||
@@ -589,7 +589,7 @@ export function WorkflowRunsPanel({
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
||||
{t("runs.promptLabel")}
|
||||
</div>
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-48 overflow-auto">
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-surface-1 border border-border rounded p-2 max-h-48 overflow-auto">
|
||||
{fullPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -598,7 +598,7 @@ export function WorkflowRunsPanel({
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted mb-0.5">
|
||||
{t("runs.resultLabel")}
|
||||
</div>
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-black/30 rounded p-2 max-h-96 overflow-auto">
|
||||
<pre className="text-[11px] text-fg-secondary whitespace-pre-wrap break-words bg-surface-1 border border-border rounded p-2 max-h-96 overflow-auto">
|
||||
{fullResult}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** 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<T extends object>(
|
||||
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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface Throttled {
|
||||
(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function throttle(fn: () => void, ms: number): Throttled {
|
||||
let last = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
+39
-29
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
+236
-190
@@ -62,12 +62,23 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo, useSyncExternalStore } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Activity, Pause, Play, RefreshCw, ChevronRight, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import { AgentStatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -76,9 +87,10 @@ import { useContextMenu } from "../components/ContextMenu";
|
||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||
import {
|
||||
EventFilters,
|
||||
EMPTY_FILTERS,
|
||||
isEmptyFilters,
|
||||
expandStatusToEventTypes,
|
||||
filtersFromParams,
|
||||
filtersToParams,
|
||||
} from "../components/EventFilters";
|
||||
import type { EventFiltersValue } from "../components/EventFilters";
|
||||
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
||||
@@ -92,52 +104,65 @@ import {
|
||||
statusFromEventType,
|
||||
} from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { formatTime, formatDateShort, timeAgo } from "../lib/format";
|
||||
import { formatTime, formatDateShort, timeAgo, isoOrUndefined } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
// Max rows a single /api/events request can return (server cap). Refreshes
|
||||
// triggered by live events are bounded by this.
|
||||
const MAX_REFRESH = 500;
|
||||
// Debounce live-event refreshes so a burst of hook events (e.g. a stream of
|
||||
// PostToolUse results) triggers one refetch instead of dozens.
|
||||
const REFRESH_DEBOUNCE_MS = 500;
|
||||
// Throttle live-event refreshes so a burst of hook events (e.g. a stream of
|
||||
// PostToolUse results) triggers one refetch instead of dozens — and a steady
|
||||
// stream still refreshes, which a trailing debounce never did.
|
||||
const REFRESH_THROTTLE_MS = 1000;
|
||||
|
||||
export function ActivityFeed() {
|
||||
const eventCtx = useContextMenu<string>();
|
||||
export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const eventCtx = useContextMenu<DashboardEvent>();
|
||||
const { t } = useTranslation("activity");
|
||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
|
||||
// Filters and page live in the URL (replace), next to History's `view`.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const paramKey = params.toString();
|
||||
const filters = useMemo(() => filtersFromParams(new URLSearchParams(paramKey)), [paramKey]);
|
||||
const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0);
|
||||
// A filter change lands on page 0 in the same update (one load, not two).
|
||||
const setFilters = useCallback(
|
||||
(f: EventFiltersValue) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = filtersToParams(f, prev);
|
||||
next.delete("page");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
),
|
||||
[setParams]
|
||||
);
|
||||
const setPage = (fn: number | ((p: number) => number)) => {
|
||||
const n = typeof fn === "function" ? fn(page) : fn;
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (n > 0) next.set("page", String(n));
|
||||
else next.delete("page");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
};
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [bufferCount, setBufferCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Events that arrived while paused, or while reading a later page (live
|
||||
// refresh only runs on page 1, so rows never shift under the reader).
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||
// session_id → session name. Populated from /api/sessions on mount so rows
|
||||
// can render a friendly session pill instead of a bare UUID.
|
||||
const [sessionNameById, setSessionNameById] = useState<Map<string, string>>(() => new Map());
|
||||
// session_id → project (basename of the session's cwd). Used as the leading
|
||||
// origin segment for events whose own payload carries no cwd (e.g.
|
||||
// TurnDuration "Turn completed" rows), so they match tool-use rows.
|
||||
const [sessionProjectById, setSessionProjectById] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
// agent_id → subagent-facing info. Populated from /api/agents on mount so the
|
||||
// subagent pill can show subagent_type (e.g. "frontend-reviewer") instead of
|
||||
// a raw ID. Main agents intentionally yield no pill.
|
||||
// agent_id → subagent-facing info for the agents of the sessions on screen,
|
||||
// fetched in one call per new set of sessions (never every agent).
|
||||
const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map());
|
||||
const knownAgentSessions = useRef(new Set<string>());
|
||||
|
||||
const bufferRef = useRef<DashboardEvent[]>([]);
|
||||
const pausedRef = useRef(paused);
|
||||
// Refs let the websocket handler read the latest filter/page without
|
||||
// re-subscribing on every change.
|
||||
const apiParamsRef = useRef<Record<string, unknown>>({});
|
||||
const pageRef = useRef(0);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
pausedRef.current = paused;
|
||||
pageRef.current = page;
|
||||
const live = page === 0 && !paused;
|
||||
|
||||
function toggleEvent(id: number) {
|
||||
setExpandedEvents((prev) => {
|
||||
@@ -159,172 +184,155 @@ export function ActivityFeed() {
|
||||
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
|
||||
session_id: filters.session_id.length > 0 ? filters.session_id : undefined,
|
||||
q: filters.q || undefined,
|
||||
from: filters.from ? new Date(filters.from).toISOString() : undefined,
|
||||
to: filters.to ? new Date(filters.to).toISOString() : undefined,
|
||||
from: isoOrUndefined(filters.from),
|
||||
to: isoOrUndefined(filters.to),
|
||||
};
|
||||
}, [filters]);
|
||||
|
||||
apiParamsRef.current = apiParams;
|
||||
|
||||
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
||||
const [scope] = useDataScope();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { events: data, total: totalCount } = await api.events.list({
|
||||
...apiParams,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
setEvents(data);
|
||||
setTotal(totalCount);
|
||||
} catch (err) {
|
||||
// Non-fatal: keep the previous list; log so dev tools surface the
|
||||
// failure instead of raising an unhandled promise rejection.
|
||||
console.error("Failed to load events:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiParams, page, scope]);
|
||||
// Newest request wins: a filter typed during a slow load never gets
|
||||
// overwritten by the older response.
|
||||
const loadSeq = useRef(0);
|
||||
const load = useCallback(
|
||||
async (quiet = false) => {
|
||||
const seq = ++loadSeq.current;
|
||||
if (!quiet) setLoading(true);
|
||||
try {
|
||||
const { events: data, total: totalCount } = await api.events.list({
|
||||
...apiParams,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
if (seq !== loadSeq.current) return;
|
||||
setEvents(data);
|
||||
setTotal(totalCount);
|
||||
setError(null);
|
||||
if (page === 0) setNewCount(0);
|
||||
} catch (err) {
|
||||
if (seq === loadSeq.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiParams, page, scope]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Reset to page 0 whenever filters change so the user lands on the first
|
||||
// page of the new filtered result set.
|
||||
// Agent labels for the sessions on screen that we have not seen yet.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [apiParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Mount-time name lookups: ask for the server's safety cap so even
|
||||
// long-running deployments get full pill coverage. These are one-shot
|
||||
// fetches; cost computation is bounded by returned rows but agents
|
||||
// don't have it and sessions only compute it on the page-sized list,
|
||||
// so this is cheap.
|
||||
api.sessions
|
||||
.list({ limit: 10000 })
|
||||
.then(({ sessions }) => {
|
||||
if (cancelled) return;
|
||||
const map = new Map<string, string>();
|
||||
const projectMap = new Map<string, string>();
|
||||
for (const s of sessions) {
|
||||
// Always populate so the EventDetail panel's "Session" row shows
|
||||
// an identifiable label even for unnamed sessions. Matches the
|
||||
// fallback used by SessionDetail's header.
|
||||
const label = s.name?.trim() || `Session ${s.id.slice(0, 8)}`;
|
||||
map.set(s.id, label);
|
||||
const project = projectFromCwd(s.cwd);
|
||||
if (project) projectMap.set(s.id, project);
|
||||
}
|
||||
setSessionNameById(map);
|
||||
setSessionProjectById(projectMap);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal: rows just render without the session name pill.
|
||||
});
|
||||
const missing = [...new Set(events.map((e) => e.session_id))].filter(
|
||||
(sid) => !knownAgentSessions.current.has(sid)
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const sid of missing) knownAgentSessions.current.add(sid);
|
||||
api.agents
|
||||
.list({ limit: 10000 })
|
||||
.then(({ agents }) => {
|
||||
if (cancelled) return;
|
||||
const map = new Map<string, AgentInfo>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, {
|
||||
type: a.type,
|
||||
subagent_type: a.subagent_type,
|
||||
name: a.name,
|
||||
parent_agent_id: a.parent_agent_id,
|
||||
});
|
||||
}
|
||||
setAgentInfoById(map);
|
||||
})
|
||||
.list({ session_ids: missing, limit: 5000 })
|
||||
.then(({ agents }) =>
|
||||
setAgentInfoById((prev) => {
|
||||
const map = new Map(prev);
|
||||
for (const a of agents)
|
||||
map.set(a.id, {
|
||||
type: a.type,
|
||||
subagent_type: a.subagent_type,
|
||||
name: a.name,
|
||||
parent_agent_id: a.parent_agent_id,
|
||||
});
|
||||
return map;
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
// Non-fatal: subagent pills fall back to the short-id label.
|
||||
for (const sid of missing) knownAgentSessions.current.delete(sid);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [events]);
|
||||
|
||||
// Refetches the current page using the latest filter state. Capped at
|
||||
// MAX_REFRESH (server limit); at the default PAGE_SIZE this is a no-op cap.
|
||||
const refreshWithPagination = useCallback(async () => {
|
||||
const size = Math.min(PAGE_SIZE, MAX_REFRESH);
|
||||
try {
|
||||
const { events: data, total: totalCount } = await api.events.list({
|
||||
...apiParamsRef.current,
|
||||
limit: size,
|
||||
offset: pageRef.current * PAGE_SIZE,
|
||||
});
|
||||
setEvents(data);
|
||||
setTotal(totalCount);
|
||||
} catch (err) {
|
||||
// Non-fatal: swallow the error so a flaky websocket burst doesn't
|
||||
// spam unhandled rejections; the next live event / manual refresh
|
||||
// will try again.
|
||||
console.error("Failed to refresh events:", err);
|
||||
}
|
||||
}, []);
|
||||
// Session labels come joined on each row (`session_name`, `session_cwd`).
|
||||
const sessionNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const e of events)
|
||||
map.set(e.session_id, e.session_name?.trim() || `Session ${e.session_id.slice(0, 8)}`);
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
// Live: page 1 refreshes (throttled); elsewhere, and while paused, new
|
||||
// events only bump a counter.
|
||||
useLiveRefresh(() => void load(true), {
|
||||
enabled: live,
|
||||
throttleMs: REFRESH_THROTTLE_MS,
|
||||
match: (msg) => msg.type === "new_event" || isRemoteDataRefreshMessage(msg),
|
||||
});
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg) => {
|
||||
// A remote source finished syncing → new remote events may exist; do a
|
||||
// filter-aware refresh (respects pause via refreshWithPagination's guard).
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
if (!pausedRef.current) refreshWithPagination();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
const event = msg.data as DashboardEvent;
|
||||
if (pausedRef.current) {
|
||||
bufferRef.current = [event, ...bufferRef.current];
|
||||
setBufferCount(bufferRef.current.length);
|
||||
return;
|
||||
}
|
||||
// Debounce bursts into a single filter-aware refresh.
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
refreshWithPagination();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
if (live) return;
|
||||
return eventBus.subscribe((msg) => {
|
||||
if (msg.type === "new_event") setNewCount((n) => n + 1);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [refreshWithPagination]);
|
||||
}, [live]);
|
||||
|
||||
function resume() {
|
||||
pausedRef.current = false;
|
||||
bufferRef.current = [];
|
||||
setBufferCount(0);
|
||||
setPaused(false);
|
||||
setNewCount(0);
|
||||
if (page !== 0) setPage(0);
|
||||
// Catch-up via filtered refresh so buffered non-matching events don't leak in.
|
||||
refreshWithPagination();
|
||||
else void load(true);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
// Precompute the project name per event so row rendering doesn't re-parse
|
||||
// event.data JSON on every render pass.
|
||||
// event.data JSON on every render pass; events without their own cwd fall
|
||||
// back to their session's.
|
||||
const projectByEventId = useMemo(() => {
|
||||
const map = new Map<number, string | null>();
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e) ?? projectFromCwd(e.session_cwd));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
async function handleExport(kind: "csv" | "json") {
|
||||
try {
|
||||
const all: DashboardEvent[] = [];
|
||||
for (let offset = 0; offset < 10000; offset += 500) {
|
||||
const res = await api.events.list({ ...apiParams, limit: 500, offset });
|
||||
all.push(...res.events);
|
||||
if (res.events.length < 500 || all.length >= res.total) break;
|
||||
}
|
||||
const name = exportName("activity", kind);
|
||||
if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json");
|
||||
else
|
||||
downloadText(
|
||||
name,
|
||||
toCsv(all, [
|
||||
"id",
|
||||
"created_at",
|
||||
"session_id",
|
||||
"session_name",
|
||||
"event_type",
|
||||
"tool_name",
|
||||
"agent_id",
|
||||
"agent_name",
|
||||
"summary",
|
||||
]),
|
||||
"text/csv"
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{eventCtx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: eventCtx.menu.item }}
|
||||
session={{
|
||||
id: eventCtx.menu.item.session_id,
|
||||
lane_id: eventCtx.menu.item.session_lane_id,
|
||||
cwd: eventCtx.menu.item.session_cwd,
|
||||
}}
|
||||
x={eventCtx.menu.x}
|
||||
y={eventCtx.menu.y}
|
||||
onClose={eventCtx.close}
|
||||
@@ -332,12 +340,17 @@ export function ActivityFeed() {
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<Activity className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<Activity className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* Under History's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||||
@@ -353,9 +366,7 @@ export function ActivityFeed() {
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("subtitle")}
|
||||
{paused && (
|
||||
<span className="ml-2 text-yellow-700 dark:text-yellow-400">
|
||||
{t("paused", { count: bufferCount })}
|
||||
</span>
|
||||
<span className="ml-2 text-status-warning">{t("paused", { count: newCount })}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -372,7 +383,18 @@ export function ActivityFeed() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={load} className="btn-ghost">
|
||||
<button onClick={() => handleExport("csv")} className="btn-ghost" disabled={total === 0}>
|
||||
<Download className="w-4 h-4" /> {t("exportCsv")}
|
||||
</button>
|
||||
<button onClick={() => handleExport("json")} className="btn-ghost" disabled={total === 0}>
|
||||
<Download className="w-4 h-4" /> {t("exportJson")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => load()}
|
||||
className="btn-ghost"
|
||||
aria-label={t("common:refresh")}
|
||||
title={t("common:refresh")}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -393,6 +415,29 @@ export function ActivityFeed() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 mb-4 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedLoad")}: {error}
|
||||
</span>
|
||||
<button onClick={() => load()} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!paused && page > 0 && newCount > 0 && (
|
||||
<button
|
||||
onClick={() => setPage(0)}
|
||||
className="mb-3 text-xs px-3 py-1 rounded-full bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25"
|
||||
>
|
||||
{t("newEvents", { count: newCount })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!loading && events.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
@@ -422,25 +467,29 @@ export function ActivityFeed() {
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
<div key={event.id ?? i} className="animate-slide-up">
|
||||
{/* The row is a plain container; its expand control is a real
|
||||
button and "Session" a sibling link (no nested interactives). */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
if (event.id != null) toggleEvent(event.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (event.id != null) toggleEvent(event.id);
|
||||
}
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
|
||||
onContextMenu={(e) => eventCtx.open(e, event)}
|
||||
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-fg-muted transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||
}
|
||||
className="-m-1 p-1 rounded flex-shrink-0 -mr-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-fg-muted transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="w-16 flex-shrink-0 text-right font-mono leading-tight">
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
@@ -455,10 +504,7 @@ export function ActivityFeed() {
|
||||
|
||||
{(() => {
|
||||
const sname = sessionNameById.get(event.session_id);
|
||||
const project =
|
||||
projectByEventId.get(event.id) ??
|
||||
sessionProjectById.get(event.session_id) ??
|
||||
null;
|
||||
const project = projectByEventId.get(event.id) ?? null;
|
||||
const origin = buildOriginLabel(
|
||||
project,
|
||||
sname ?? null,
|
||||
@@ -521,7 +567,7 @@ export function ActivityFeed() {
|
||||
onClick={() => setPage(0)}
|
||||
disabled={page === 0}
|
||||
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
aria-label="First page"
|
||||
aria-label={t("firstPage")}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
@@ -584,7 +630,7 @@ export function ActivityFeed() {
|
||||
onClick={() => setPage(totalPages - 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-fg-secondary hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
aria-label="Last page"
|
||||
aria-label={t("lastPage")}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
|
||||
+665
-465
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ interface HistoryView {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
Panel: ComponentType;
|
||||
Panel: ComponentType<{ embedded?: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +42,10 @@ export function History() {
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === defaultView.id ? {} : { view: id }, { replace: true })}
|
||||
tabs={views.map(({ id, label, Icon }) => ({ id, label, Icon }))}
|
||||
/>
|
||||
<active.Panel />
|
||||
>
|
||||
{/* `embedded`: the tab bar names the view, so the panel drops its own title. */}
|
||||
<active.Panel embedded />
|
||||
</PageTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+181
-124
@@ -57,7 +57,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import { Link, useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -77,9 +77,14 @@ import {
|
||||
ExternalLink,
|
||||
Workflow,
|
||||
Hourglass,
|
||||
X,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { workspaceHref } from "../components/SessionRowMenu";
|
||||
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { AgentCard } from "../components/AgentCard";
|
||||
import { SessionOverview } from "../components/SessionOverview";
|
||||
@@ -97,9 +102,10 @@ import {
|
||||
import { EventDetail } from "../components/EventDetail";
|
||||
import {
|
||||
EventFilters,
|
||||
EMPTY_FILTERS,
|
||||
isEmptyFilters,
|
||||
expandStatusToEventTypes,
|
||||
filtersFromParams,
|
||||
filtersToParams,
|
||||
} from "../components/EventFilters";
|
||||
import type { EventFiltersValue } from "../components/EventFilters";
|
||||
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
||||
@@ -119,6 +125,8 @@ import {
|
||||
fmtCostFull,
|
||||
timeAgo,
|
||||
formatModelName,
|
||||
parseDate,
|
||||
isoOrUndefined,
|
||||
} from "../lib/format";
|
||||
import type {
|
||||
Session,
|
||||
@@ -127,6 +135,7 @@ import type {
|
||||
CostResult,
|
||||
TranscriptInfo,
|
||||
WorkflowRun,
|
||||
WSMessage,
|
||||
} from "../lib/types";
|
||||
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
|
||||
|
||||
@@ -136,7 +145,8 @@ const EVENTS_INITIAL_BATCH = 50;
|
||||
const EVENTS_MORE_BATCH = 500;
|
||||
// Live-refresh bounds - see ActivityFeed for rationale.
|
||||
const EVENTS_MAX_REFRESH = 500;
|
||||
const EVENTS_REFRESH_DEBOUNCE_MS = 500;
|
||||
const EVENTS_REFRESH_THROTTLE_MS = 1000;
|
||||
const DETAIL_TABS: DetailTab[] = ["agents", "conversation", "timeline"];
|
||||
|
||||
export function SessionDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -149,17 +159,41 @@ export function SessionDetail() {
|
||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||
const [eventsTotal, setEventsTotal] = useState(0);
|
||||
const [eventsLoadingMore, setEventsLoadingMore] = useState(false);
|
||||
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
|
||||
// Tab and timeline filters live in the URL (replace), so a reload or a
|
||||
// shared link reopens the same view.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const paramKey = params.toString();
|
||||
const filters = useMemo(() => filtersFromParams(new URLSearchParams(paramKey)), [paramKey]);
|
||||
const setFilters = useCallback(
|
||||
(f: EventFiltersValue) => setParams((prev) => filtersToParams(f, prev), { replace: true }),
|
||||
[setParams]
|
||||
);
|
||||
const [cost, setCost] = useState<CostResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// True when this session is currently being driven by an in-flight Run
|
||||
// handle on /run. Drives the "Open in Run page" banner up top.
|
||||
const [isDashboardRun, setIsDashboardRun] = useState(false);
|
||||
// Live dashboard runs: one holding THIS session drives the "open its pane" banner.
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
const isDashboardRun = runs.some((h) => h.sessionId === id && h.status === "running");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(() => {
|
||||
return new Set<string>();
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("agents");
|
||||
const tabParam = params.get("tab");
|
||||
const activeTab: DetailTab = DETAIL_TABS.includes(tabParam as DetailTab)
|
||||
? (tabParam as DetailTab)
|
||||
: "agents";
|
||||
const setActiveTab = useCallback(
|
||||
(tab: DetailTab) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === "agents") next.delete("tab");
|
||||
else next.set("tab", tab);
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
),
|
||||
[setParams]
|
||||
);
|
||||
// Keep tabs mounted once visited so switching between them doesn't unmount/
|
||||
// remount their subtrees (which causes a perceptible flash on click).
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<DetailTab>>(() => new Set(["agents"]));
|
||||
@@ -184,8 +218,6 @@ export function SessionDetail() {
|
||||
// Refs let the websocket handler access latest state without re-subscribing.
|
||||
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||
const eventsLoadedCountRef = useRef(0);
|
||||
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
eventsLoadedCountRef.current = events.length;
|
||||
const goBack = useCallback(() => {
|
||||
const historyState =
|
||||
@@ -208,34 +240,20 @@ export function SessionDetail() {
|
||||
}, [transcriptNotFound]);
|
||||
|
||||
// Probe /api/run to see whether THIS session is currently being driven by
|
||||
// an in-flight Run handle. If so, surface a banner so the user can hop
|
||||
// back to /run instead of viewing this as a passive transcript.
|
||||
// a dashboard pane. If so, a banner links straight to that pane.
|
||||
const probeRuns = useCallback(() => {
|
||||
// Defensive: tests mock the api module without a `run` namespace.
|
||||
if (!api.run || typeof api.run.list !== "function") return;
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
const probe = () => {
|
||||
// Defensive: tests mock the api module without a `run` namespace.
|
||||
if (!api.run || typeof api.run.list !== "function") return;
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
const live = r.items.some((h) => h.sessionId === id && h.status === "running");
|
||||
setIsDashboardRun(live);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
probe();
|
||||
const t = setInterval(probe, 10000);
|
||||
const unsub = eventBus.subscribe((msg) => {
|
||||
if (msg.type === "run_status") probe();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
unsub();
|
||||
};
|
||||
}, [id]);
|
||||
probeRuns();
|
||||
}, [probeRuns]);
|
||||
// A pane starting/stopping re-broadcasts its lane (`lane_update`).
|
||||
useLiveRefresh(probeRuns, { intervalMs: 10000, match: (msg) => msg.type === "lane_update" });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
@@ -350,7 +368,7 @@ export function SessionDetail() {
|
||||
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
|
||||
compactions.forEach((a, i) => {
|
||||
const time = a.started_at
|
||||
? new Date(a.started_at).toLocaleTimeString(undefined, {
|
||||
? parseDate(a.started_at).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
@@ -374,8 +392,8 @@ export function SessionDetail() {
|
||||
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
|
||||
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
|
||||
q: filters.q || undefined,
|
||||
from: filters.from ? new Date(filters.from).toISOString() : undefined,
|
||||
to: filters.to ? new Date(filters.to).toISOString() : undefined,
|
||||
from: isoOrUndefined(filters.from),
|
||||
to: isoOrUndefined(filters.to),
|
||||
};
|
||||
}, [id, filters]);
|
||||
|
||||
@@ -497,52 +515,58 @@ export function SessionDetail() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
load();
|
||||
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
|
||||
eventsRefreshTimerRef.current = setTimeout(() => {
|
||||
eventsRefreshTimerRef.current = null;
|
||||
refreshEventsWithPagination();
|
||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
// Only this session's messages: every hook of every session broadcasts,
|
||||
// and a reload per message re-read this page's whole session each time.
|
||||
const d = msg.data as { id?: string; session_id?: string } | undefined;
|
||||
const mine = d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
|
||||
if (!mine) return;
|
||||
if (
|
||||
msg.type === "agent_created" ||
|
||||
// Only this session's messages: every hook of every session broadcasts, and
|
||||
// a reload per message re-read this page's whole session each time. Both
|
||||
// refreshes are throttled (a steady stream still updates, never freezes) and
|
||||
// re-run after a WebSocket reconnect.
|
||||
const isMine = (msg: WSMessage) => {
|
||||
const d = msg.data as { id?: string; session_id?: string } | undefined;
|
||||
return d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
|
||||
};
|
||||
useLiveRefresh(load, {
|
||||
match: (msg) =>
|
||||
isRemoteDataRefreshMessage(msg) ||
|
||||
((msg.type === "agent_created" ||
|
||||
msg.type === "agent_updated" ||
|
||||
msg.type === "session_updated"
|
||||
) {
|
||||
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
||||
loadTimerRef.current = setTimeout(() => {
|
||||
loadTimerRef.current = null;
|
||||
load();
|
||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||
msg.type === "session_updated") &&
|
||||
isMine(msg)),
|
||||
});
|
||||
// A filter-aware refetch that preserves the current "Load more" size.
|
||||
useLiveRefresh(refreshEventsWithPagination, {
|
||||
throttleMs: EVENTS_REFRESH_THROTTLE_MS,
|
||||
match: (msg) => isRemoteDataRefreshMessage(msg) || (msg.type === "new_event" && isMine(msg)),
|
||||
});
|
||||
|
||||
async function exportEvents(kind: "csv" | "json") {
|
||||
if (!eventApiParams) return;
|
||||
try {
|
||||
// The whole filtered timeline, a page at a time (the route caps a page at 500).
|
||||
const all: DashboardEvent[] = [];
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const page = await api.events.list({ ...eventApiParams, limit: 500, offset });
|
||||
all.push(...page.events);
|
||||
if (page.events.length < 500 || all.length >= page.total) break;
|
||||
}
|
||||
if (msg.type === "new_event") {
|
||||
// Debounce bursts into one filter-aware refetch that preserves the
|
||||
// current "Load more" pagination size.
|
||||
if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current);
|
||||
eventsRefreshTimerRef.current = setTimeout(() => {
|
||||
eventsRefreshTimerRef.current = null;
|
||||
refreshEventsWithPagination();
|
||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
||||
if (eventsRefreshTimerRef.current) {
|
||||
clearTimeout(eventsRefreshTimerRef.current);
|
||||
eventsRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [id, load, refreshEventsWithPagination]);
|
||||
const name = exportName(`session-${id?.slice(0, 8)}-events`, kind);
|
||||
if (kind === "json") downloadText(name, JSON.stringify(all, null, 2), "application/json");
|
||||
else
|
||||
downloadText(
|
||||
name,
|
||||
toCsv(all, [
|
||||
"id",
|
||||
"created_at",
|
||||
"event_type",
|
||||
"tool_name",
|
||||
"agent_id",
|
||||
"agent_name",
|
||||
"summary",
|
||||
]),
|
||||
"text/csv"
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -596,14 +620,14 @@ export function SessionDetail() {
|
||||
<div className="animate-fade-in space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button onClick={goBack} className="btn-ghost mt-1">
|
||||
<button onClick={goBack} className="btn-ghost mt-1" aria-label={t("detail.back")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-xl font-semibold text-fg-primary">
|
||||
<h1 className="text-xl font-semibold text-fg-primary">
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</h2>
|
||||
</h1>
|
||||
<SessionToolBadge tool={session.tool} />
|
||||
<SessionStatusBadge
|
||||
status={effectiveSessionStatus(session)}
|
||||
@@ -647,8 +671,21 @@ export function SessionDetail() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const href = workspaceHref(session, runs);
|
||||
return href ? (
|
||||
<Link to={href} className="btn-ghost">
|
||||
<ExternalLink className="w-4 h-4" /> {t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<button onClick={load} className="btn-ghost">
|
||||
<button
|
||||
onClick={load}
|
||||
className="btn-ghost"
|
||||
aria-label={t("common:refresh")}
|
||||
title={t("common:refresh")}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -670,40 +707,34 @@ export function SessionDetail() {
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
|
||||
urgent
|
||||
? "border-status-warning/40 bg-status-warning/[0.08]"
|
||||
: "border-yellow-500/25 bg-yellow-500/[0.05]"
|
||||
: "border-status-warning/25 bg-status-warning/[0.05]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
|
||||
urgent
|
||||
? "bg-status-warning/15 border-status-warning/30"
|
||||
: "bg-yellow-500/10 border-yellow-500/25"
|
||||
: "bg-status-warning/10 border-status-warning/25"
|
||||
}`}
|
||||
>
|
||||
<ReasonIcon
|
||||
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
|
||||
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-status-warning"}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
|
||||
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-status-warning"}`}
|
||||
>
|
||||
{t("detail.waitingBanner.title")}
|
||||
{cfg && (
|
||||
<span
|
||||
className={
|
||||
urgent
|
||||
? "text-status-warning/90"
|
||||
: "text-yellow-700/80 dark:text-yellow-300/80"
|
||||
}
|
||||
>
|
||||
<span className={urgent ? "text-status-warning/90" : "text-status-warning/80"}>
|
||||
{" · "}
|
||||
{t(cfg.labelKey)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
|
||||
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-status-warning/70"}`}
|
||||
>
|
||||
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
|
||||
</div>
|
||||
@@ -711,12 +742,12 @@ export function SessionDetail() {
|
||||
{session.awaiting_input_since && (
|
||||
<span
|
||||
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
|
||||
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
|
||||
urgent ? "text-status-warning/80" : "text-status-warning/80"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${
|
||||
urgent ? "bg-status-warning" : "bg-yellow-400"
|
||||
urgent ? "bg-status-warning" : "bg-status-warning"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -727,9 +758,9 @@ export function SessionDetail() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{isDashboardRun && (
|
||||
{isDashboardRun && workspaceHref(session, runs) && (
|
||||
<Link
|
||||
to={`/run?session=${encodeURIComponent(id || "")}`}
|
||||
to={workspaceHref(session, runs)!}
|
||||
className="flex items-center gap-3 rounded-lg border border-status-success/30 bg-status-success/[0.06] hover:bg-status-success/[0.12] hover:border-status-success/50 px-4 py-2.5 transition-colors group"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-md bg-status-success/15 border border-status-success/30 inline-flex items-center justify-center flex-shrink-0">
|
||||
@@ -737,13 +768,10 @@ export function SessionDetail() {
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-status-success">
|
||||
{t("detail.dashboardRun.title", "This session is being driven from the Run page")}
|
||||
{t("detail.dashboardRun.title")}
|
||||
</div>
|
||||
<div className="text-[11px] text-status-success/70">
|
||||
{t(
|
||||
"detail.dashboardRun.body",
|
||||
"Send follow-ups, watch streaming output, or stop the run from there."
|
||||
)}
|
||||
{t("detail.dashboardRun.body")}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-status-success/70 group-hover:text-status-success flex-shrink-0" />
|
||||
@@ -759,7 +787,7 @@ export function SessionDetail() {
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "agents"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
@@ -773,7 +801,7 @@ export function SessionDetail() {
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "conversation"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
@@ -787,7 +815,7 @@ export function SessionDetail() {
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "timeline"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
@@ -804,8 +832,9 @@ export function SessionDetail() {
|
||||
<button
|
||||
onClick={() => setTranscriptNotFound(false)}
|
||||
className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
|
||||
aria-label={t("common:dismiss")}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -817,7 +846,7 @@ export function SessionDetail() {
|
||||
{workflows.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
<Workflow className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||
{wfT("runs.sessionTitle")}
|
||||
<span className="text-fg-muted font-mono">· {workflows.length}</span>
|
||||
</h3>
|
||||
@@ -830,7 +859,7 @@ export function SessionDetail() {
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
<Bot className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||
{t("detail.agents")}
|
||||
<span className="text-fg-muted font-mono">· {agents.length}</span>
|
||||
</h3>
|
||||
@@ -896,7 +925,11 @@ export function SessionDetail() {
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
|
||||
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("detail.collapseSubagents")
|
||||
: t("detail.expandSubagents")
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? (
|
||||
@@ -908,7 +941,7 @@ export function SessionDetail() {
|
||||
)}
|
||||
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
|
||||
{isSubagent && (
|
||||
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
<GitBranch className="w-3 h-3 text-syntax-keyword flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<AgentCard
|
||||
@@ -938,7 +971,7 @@ export function SessionDetail() {
|
||||
|
||||
{/* Recursive children (collapsible) */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="ml-6 mt-1 space-y-1 border-l-2 border-violet-500/20 pl-3">
|
||||
<div className="ml-6 mt-1 space-y-1 border-l-2 border-syntax-keyword/20 pl-3">
|
||||
{children.map((child) =>
|
||||
renderAgentNode(child, depth + 1, childAncestors)
|
||||
)}
|
||||
@@ -951,7 +984,7 @@ export function SessionDetail() {
|
||||
onClick={() =>
|
||||
setExpandedAgents((prev) => new Set([...prev, agent.id]))
|
||||
}
|
||||
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
|
||||
className="ml-7 mt-1 text-[11px] text-syntax-keyword hover:text-fg-primary transition-colors"
|
||||
>
|
||||
{t("common:subagent_label", { count: totalDesc })}
|
||||
</button>
|
||||
@@ -1074,8 +1107,24 @@ export function SessionDetail() {
|
||||
|
||||
{visitedTabs.has("timeline") && (
|
||||
<div hidden={activeTab !== "timeline"}>
|
||||
<div className="mb-3">
|
||||
<EventFiltersInfo />
|
||||
<div className="mb-3 flex flex-wrap items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<EventFiltersInfo />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => exportEvents("csv")}
|
||||
className="btn-ghost text-xs"
|
||||
disabled={eventsTotal === 0}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> {t("exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportEvents("json")}
|
||||
className="btn-ghost text-xs"
|
||||
disabled={eventsTotal === 0}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> {t("exportJson")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<EventFilters
|
||||
@@ -1096,7 +1145,15 @@ export function SessionDetail() {
|
||||
const key = event.id ?? i;
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
<div key={key}>
|
||||
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||
<div
|
||||
key={key}
|
||||
style={
|
||||
isOpen
|
||||
? undefined
|
||||
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
|
||||
+334
-151
@@ -52,8 +52,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useSyncExternalStore } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback, useSyncExternalStore } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FolderOpen,
|
||||
@@ -62,12 +62,18 @@ import {
|
||||
RefreshCw,
|
||||
SortDesc,
|
||||
SortAsc,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Server,
|
||||
Download,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { RemoteSource } from "../lib/api";
|
||||
import type { RemoteSource, RunHandle } from "../lib/api";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { toast } from "../lib/toast";
|
||||
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
@@ -76,35 +82,60 @@ import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { useContextMenu } from "../components/ContextMenu";
|
||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
isSessionAwaitingInput,
|
||||
sessionAwaitingReason,
|
||||
} from "../lib/types";
|
||||
import { SessionRowMenu, workspaceHref } from "../components/SessionRowMenu";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost, isoOrUndefined } from "../lib/format";
|
||||
import { effectiveSessionStatus, sessionAwaitingReason } from "../lib/types";
|
||||
import type { Session, DashboardEvent } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function Sessions() {
|
||||
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const rowCtx = useContextMenu<Session>();
|
||||
const { t } = useTranslation("sessions");
|
||||
const { tools } = useAiTools();
|
||||
const { lanes } = useLanes();
|
||||
// Filters, sort and page live in the URL (replace, no history spam), so a
|
||||
// reload, the back button and a shared link all land on the same view.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const filter = params.get("status") ?? "";
|
||||
const search = params.get("q") ?? "";
|
||||
const cwd = params.get("cwd") ?? "";
|
||||
const tool = params.get("tool") ?? "";
|
||||
const lane = params.get("lane") ?? "";
|
||||
const from = params.get("from") ?? "";
|
||||
const to = params.get("to") ?? "";
|
||||
const sortBy = params.get("sort") ?? "time";
|
||||
const sortDesc = params.get("asc") !== "1";
|
||||
const page = Math.max(0, parseInt(params.get("page") ?? "0", 10) || 0);
|
||||
/** Writes params; any change but the page itself goes back to page 0 in the
|
||||
* SAME update, so one filter change is one load. */
|
||||
const update = useCallback(
|
||||
(patch: Record<string, string | null>) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) next.set(k, v);
|
||||
else next.delete(k);
|
||||
}
|
||||
if (!("page" in patch)) next.delete("page");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
),
|
||||
[setParams]
|
||||
);
|
||||
const setPage = (fn: (p: number) => number) => update({ page: String(fn(page) || "") });
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filter, setFilter] = useState("");
|
||||
// `searchInput` is what the user types; `search` is the debounced value
|
||||
// actually sent to the server. Without debouncing, every keystroke would
|
||||
// hit /api/sessions.
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
// `searchInput` is what the user types; `q` in the URL is the debounced value
|
||||
// actually sent to the server.
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const [cwd, setCwd] = useState("");
|
||||
const [sortBy, setSortBy] = useState("time");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [directories, setDirectories] = useState<string[]>([]);
|
||||
// Global data scope (which source machines to show). Included in `load`'s deps
|
||||
// so switching scope re-fetches; the actual `sources` param is injected by the
|
||||
@@ -112,9 +143,8 @@ export function Sessions() {
|
||||
const [scope] = useDataScope();
|
||||
// source id → label, so remote-origin rows show a friendly badge.
|
||||
const [sourceLabels, setSourceLabels] = useState<Map<string, string>>(() => new Map());
|
||||
// Set of session IDs that are currently being driven by an in-flight Run
|
||||
// handle on /run. Lets us badge those rows with a "Run" link.
|
||||
const [dashboardRunIds, setDashboardRunIds] = useState<Set<string>>(new Set());
|
||||
// Live dashboard runs, so a row a pane holds links straight to that pane.
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
|
||||
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: t("filterAll"), value: "" },
|
||||
@@ -128,9 +158,17 @@ export function Sessions() {
|
||||
// Debounce the search input → 300 ms after the user stops typing, the
|
||||
// committed value flips and triggers a fresh fetch.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setSearch(searchInput.trim()), 300);
|
||||
const id = window.setTimeout(() => {
|
||||
if (searchInput.trim() !== search) update({ q: searchInput.trim() || null });
|
||||
}, 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [searchInput]);
|
||||
}, [searchInput, search, update]);
|
||||
|
||||
// `?q=` changed from outside (a link to plain /sessions, back/forward): the
|
||||
// box follows, so the debounce above never writes the old text back.
|
||||
useEffect(() => {
|
||||
setSearchInput((cur) => (cur.trim() === search ? cur : search));
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
api.sessions
|
||||
@@ -156,115 +194,131 @@ export function Sessions() {
|
||||
loadSourceLabels();
|
||||
}, [loadSourceLabels]);
|
||||
|
||||
// Server-side pagination: only the visible page is fetched. Cost
|
||||
// computation on the server scales with PAGE_SIZE, not with the total
|
||||
// session count, so this stays cheap regardless of how many sessions
|
||||
// exist in the database.
|
||||
/** The list query for the current filters (paging added by the caller). */
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
// "Waiting" is an overlay on active sessions (awaiting_input_since);
|
||||
// the server filters it before the limit so paging stays exact.
|
||||
status: filter === "waiting" ? "active" : filter || undefined,
|
||||
awaiting: filter === "waiting" || undefined,
|
||||
q: search || undefined,
|
||||
cwd: cwd || undefined,
|
||||
tool: tool || undefined,
|
||||
lane: lane ? Number(lane) : undefined,
|
||||
// A date input is a local calendar day: `to` covers that whole day.
|
||||
from: isoOrUndefined(from && `${from}T00:00:00`),
|
||||
to: isoOrUndefined(to && `${to}T23:59:59.999`),
|
||||
sort_by: sortBy,
|
||||
sort_desc: sortDesc,
|
||||
}),
|
||||
[filter, search, cwd, tool, lane, from, to, sortBy, sortDesc]
|
||||
);
|
||||
|
||||
// Server-side pagination: only the visible page is fetched. A newer request
|
||||
// (filter typed while a load is in flight) wins; older responses are dropped.
|
||||
const loadSeq = useRef(0);
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
try {
|
||||
// The "waiting" filter is a UI-only overlay derived from the
|
||||
// awaiting_input_since column - the underlying SessionStatus is
|
||||
// still "active". Map it to a client-side filter on top of the
|
||||
// active set so paging/totals stay consistent with the visible rows.
|
||||
if (filter === "waiting") {
|
||||
const res = await api.sessions.list({
|
||||
status: "active",
|
||||
q: search || undefined,
|
||||
cwd: cwd || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_desc: sortDesc,
|
||||
limit: 10000,
|
||||
offset: 0,
|
||||
});
|
||||
const waiting = res.sessions.filter(isSessionAwaitingInput);
|
||||
setTotal(waiting.length);
|
||||
setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE));
|
||||
return;
|
||||
}
|
||||
const params: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
cwd?: string;
|
||||
sort_by?: string;
|
||||
sort_desc?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} = {
|
||||
const res = await api.sessions.list({
|
||||
...query,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
sort_by: sortBy,
|
||||
sort_desc: sortDesc,
|
||||
};
|
||||
if (filter) params.status = filter;
|
||||
if (search) params.q = search;
|
||||
if (cwd) params.cwd = cwd;
|
||||
const res = await api.sessions.list(params);
|
||||
});
|
||||
if (seq !== loadSeq.current) return;
|
||||
setSessions(res.sessions);
|
||||
setTotal(res.total);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (seq === loadSeq.current) setError(err instanceof Error ? err.message : t("failedList"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
|
||||
// the matching `sources` param.
|
||||
}, [filter, search, cwd, sortBy, sortDesc, page, scope]);
|
||||
}, [query, page, scope, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Reset to page 0 whenever filters or sort changes.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [filter, search, cwd, sortBy, sortDesc]);
|
||||
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg) => {
|
||||
if (msg.type === "session_created" || msg.type === "session_updated") {
|
||||
load();
|
||||
}
|
||||
if (msg.type === "new_event") {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") {
|
||||
load();
|
||||
}
|
||||
}
|
||||
if (msg.type === "run_status") {
|
||||
loadDashboardRuns();
|
||||
}
|
||||
// A remote source finished syncing: new remote sessions may have landed.
|
||||
if (msg.type === "remote_source.status") {
|
||||
loadSourceLabels();
|
||||
if (isRemoteDataRefreshMessage(msg)) load();
|
||||
} else if (isRemoteDataRefreshMessage(msg)) {
|
||||
load();
|
||||
}
|
||||
});
|
||||
// loadDashboardRuns is a stable useCallback declared below; referenced at
|
||||
// event time only (not in deps) to avoid a temporal-dead-zone at render.
|
||||
}, [load, loadSourceLabels]);
|
||||
|
||||
// Pull active Run handles so we can mark which sessions are being driven
|
||||
// from /run right now. Refresh on mount, on run_status WS messages, and
|
||||
// every 15s as a safety net for stale browser state.
|
||||
// Pull live Run handles so a row a dashboard pane holds can open that pane.
|
||||
const loadDashboardRuns = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => {
|
||||
const ids = new Set<string>();
|
||||
for (const h of r.items) {
|
||||
if (h.sessionId) ids.add(h.sessionId);
|
||||
}
|
||||
setDashboardRunIds(ids);
|
||||
})
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardRuns();
|
||||
const t = setInterval(loadDashboardRuns, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadDashboardRuns]);
|
||||
useLiveRefresh(loadDashboardRuns, {
|
||||
intervalMs: 15000,
|
||||
// A pane starting/stopping re-broadcasts its lane (`lane_update`); the
|
||||
// server sends no run_status any more. The 15 s poll covers extra slots.
|
||||
match: (msg) => msg.type === "lane_update",
|
||||
});
|
||||
|
||||
useLiveRefresh(load, {
|
||||
match: (msg) => {
|
||||
if (msg.type === "session_created" || msg.type === "session_updated") return true;
|
||||
if (msg.type === "new_event") {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
return ev.event_type === "Stop" || ev.event_type === "SessionEnd";
|
||||
}
|
||||
// A remote source finished syncing: new remote sessions may have landed.
|
||||
return isRemoteDataRefreshMessage(msg);
|
||||
},
|
||||
});
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "remote_source.status") loadSourceLabels();
|
||||
}),
|
||||
[loadSourceLabels]
|
||||
);
|
||||
|
||||
async function handleExport(kind: "csv" | "json") {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await api.sessions.list({ ...query, limit: 10000 });
|
||||
const name = exportName("sessions", kind);
|
||||
if (kind === "json")
|
||||
downloadText(name, JSON.stringify(res.sessions, null, 2), "application/json");
|
||||
else
|
||||
downloadText(
|
||||
name,
|
||||
toCsv(res.sessions, [
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"tool",
|
||||
"cwd",
|
||||
"model",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"agent_count",
|
||||
"cost",
|
||||
"lane_id",
|
||||
]),
|
||||
"text/csv"
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = !!(search || filter || cwd || tool || lane || from || to);
|
||||
|
||||
// A `?page=` past the end (a stale link, rows deleted meanwhile) goes back to
|
||||
// the first page rather than showing an empty table.
|
||||
const pageCount = Math.ceil(total / PAGE_SIZE);
|
||||
useEffect(() => {
|
||||
if (!loading && pageCount > 0 && page >= pageCount) update({ page: null });
|
||||
}, [loading, page, pageCount, update]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
// The server already paginates, so the rendered page IS the loaded list.
|
||||
const paged = sessions;
|
||||
@@ -284,12 +338,17 @@ export function Sessions() {
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<FolderOpen className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<FolderOpen className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* Under History's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
{wsConnected ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||||
@@ -304,13 +363,31 @@ export function Sessions() {
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("sessionCount", { count: total })}
|
||||
{filter ? ` ${filter}` : ""}
|
||||
{filter
|
||||
? ` · ${FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? filter}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleExport("csv")}
|
||||
className="btn-ghost"
|
||||
disabled={exporting || total === 0}
|
||||
>
|
||||
<Download className="w-4 h-4" /> {t("exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport("json")}
|
||||
className="btn-ghost"
|
||||
disabled={exporting || total === 0}
|
||||
>
|
||||
<Download className="w-4 h-4" /> {t("exportJson")}
|
||||
</button>
|
||||
<button onClick={load} className="btn-ghost">
|
||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -321,6 +398,7 @@ export function Sessions() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchPlaceholder")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="input w-full pl-10"
|
||||
@@ -331,17 +409,17 @@ export function Sessions() {
|
||||
<div className="relative shrink-0 w-[180px]">
|
||||
<select
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
className="input w-full text-ellipsis bg-surface-1 pr-9 appearance-none cursor-pointer"
|
||||
onChange={(e) => update({ cwd: e.target.value || null })}
|
||||
aria-label={t("filterDirectory")}
|
||||
className="select w-full text-ellipsis"
|
||||
>
|
||||
<option value="">All Directories</option>
|
||||
<option value="">{t("allDirectories")}</option>
|
||||
{directories.map((d) => (
|
||||
<option key={d} value={d} title={d}>
|
||||
{truncate(d, 30)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Sort Controls */}
|
||||
@@ -349,22 +427,23 @@ export function Sessions() {
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="bg-transparent w-full text-sm text-fg-secondary outline-none pl-3 pr-8 appearance-none cursor-pointer whitespace-nowrap"
|
||||
onChange={(e) => update({ sort: e.target.value === "time" ? null : e.target.value })}
|
||||
aria-label={t("sortLabel")}
|
||||
className="select w-full border-0 bg-transparent py-0 whitespace-nowrap"
|
||||
>
|
||||
<option value="time">Sort by Time ({sortDesc ? "Newest" : "Oldest"})</option>
|
||||
<option value="time">{sortDesc ? t("sortTimeDesc") : t("sortTimeAsc")}</option>
|
||||
<option value="duration">
|
||||
Sort by Duration ({sortDesc ? "Longest" : "Shortest"})
|
||||
{sortDesc ? t("sortDurationDesc") : t("sortDurationAsc")}
|
||||
</option>
|
||||
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
|
||||
<option value="price">{sortDesc ? t("sortPriceDesc") : t("sortPriceAsc")}</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button
|
||||
onClick={() => setSortDesc(!sortDesc)}
|
||||
onClick={() => update({ asc: sortDesc ? "1" : null })}
|
||||
className="p-1.5 rounded hover:bg-surface-3 text-fg-secondary hover:text-fg-primary transition-colors shrink-0"
|
||||
title={sortDesc ? "Descending" : "Ascending"}
|
||||
title={sortDesc ? t("descending") : t("ascending")}
|
||||
aria-label={sortDesc ? t("descending") : t("ascending")}
|
||||
>
|
||||
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
|
||||
</button>
|
||||
@@ -375,7 +454,8 @@ export function Sessions() {
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setFilter(opt.value)}
|
||||
onClick={() => update({ status: opt.value || null })}
|
||||
aria-pressed={filter === opt.value}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
|
||||
filter === opt.value
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
@@ -388,15 +468,106 @@ export function Sessions() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && filtered.length === 0 ? (
|
||||
{/* Tool / workspace / date range */}
|
||||
<div className="flex flex-wrap items-center gap-3 -mt-3 mb-6 text-xs">
|
||||
<select
|
||||
value={tool}
|
||||
onChange={(e) => update({ tool: e.target.value || null })}
|
||||
aria-label={t("filterTool")}
|
||||
className="select py-1 text-xs w-auto"
|
||||
>
|
||||
<option value="">{t("allTools")}</option>
|
||||
{tools
|
||||
.filter((x) => x.installed || x.id === tool)
|
||||
.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={lane}
|
||||
onChange={(e) => update({ lane: e.target.value || null })}
|
||||
aria-label={t("filterWorkspace")}
|
||||
className="select py-1 text-xs w-auto max-w-[16rem]"
|
||||
>
|
||||
<option value="">{t("allWorkspaces")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={String(l.id)}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-fg-muted">
|
||||
{t("dateFrom")}
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => update({ from: e.target.value || null })}
|
||||
className="input py-1 text-xs w-auto"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-fg-muted">
|
||||
{t("dateTo")}
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => update({ to: e.target.value || null })}
|
||||
className="input py-1 text-xs w-auto"
|
||||
/>
|
||||
</label>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchInput("");
|
||||
update({
|
||||
status: null,
|
||||
q: null,
|
||||
cwd: null,
|
||||
tool: null,
|
||||
lane: null,
|
||||
from: null,
|
||||
to: null,
|
||||
});
|
||||
}}
|
||||
className="text-fg-muted hover:text-fg-primary underline"
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 mb-4 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedList")}: {error}
|
||||
</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={t("noSessions")}
|
||||
description={search || filter || cwd ? t("noSessionsDesc") : t("noSessionsHint")}
|
||||
description={hasFilters ? t("noSessionsDesc") : t("noSessionsHint")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="card overflow-x-auto">
|
||||
<div
|
||||
className="card overflow-x-auto"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t("tableRegion")}
|
||||
>
|
||||
<table className="w-full min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
@@ -437,37 +608,49 @@ export function Sessions() {
|
||||
{paged.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
// Mouse convenience; the session name below is the real,
|
||||
// focusable link (a click on a link or button inside wins).
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
navigate(`/sessions/${session.id}`);
|
||||
}}
|
||||
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-fg-secondary">
|
||||
<Link
|
||||
to={`/sessions/${session.id}`}
|
||||
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
|
||||
>
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</p>
|
||||
</Link>
|
||||
<SessionToolBadge tool={session.tool} />
|
||||
{session.source && session.source !== "local" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
|
||||
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
|
||||
>
|
||||
<Server className="w-2.5 h-2.5" />
|
||||
{sourceLabels.get(session.source) || session.source}
|
||||
</span>
|
||||
)}
|
||||
{dashboardRunIds.has(session.id) && (
|
||||
<Link
|
||||
to={`/run?session=${encodeURIComponent(session.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
|
||||
title={t("dashboardRunBadge", "Driven by Run page · click to open")}
|
||||
>
|
||||
<Play className="w-2.5 h-2.5" />
|
||||
{t("common:dashboardRun", "Run")}
|
||||
</Link>
|
||||
)}
|
||||
{(() => {
|
||||
// A live dashboard pane holds it: open that pane.
|
||||
const run = runs.find((r) => r.sessionId === session.id);
|
||||
const href = run ? workspaceHref(session, runs) : null;
|
||||
return href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
|
||||
title={t("openInWorkspaceTitle")}
|
||||
>
|
||||
<Play className="w-2.5 h-2.5" />
|
||||
{t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono">
|
||||
{session.id.slice(0, 12)}
|
||||
|
||||
@@ -76,6 +76,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import type { WorkflowData, WSMessage } from "../lib/types";
|
||||
|
||||
import { WorkflowStats } from "../components/workflows/WorkflowStats";
|
||||
@@ -90,7 +91,7 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
|
||||
|
||||
type StatusFilter = "all" | "active" | "completed";
|
||||
|
||||
export function Workflows() {
|
||||
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<WorkflowData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -100,36 +101,49 @@ export function Workflows() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const [hasPipelines, setHasPipelines] = useState(false);
|
||||
const drillInRef = useRef<HTMLDivElement>(null);
|
||||
// A filter switch can overtake a slow poll; only the newest response lands.
|
||||
const fetchSeq = useRef(0);
|
||||
const fetchData = useCallback(async () => {
|
||||
const seq = ++fetchSeq.current;
|
||||
try {
|
||||
setError(null);
|
||||
const result = await api.workflows.get(statusFilter);
|
||||
if (seq !== fetchSeq.current) return;
|
||||
setError(null);
|
||||
setData(result);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("failedLoad"));
|
||||
if (seq === fetchSeq.current) setError(err instanceof Error ? err.message : t("failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === fetchSeq.current) setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
const selectSession = useCallback((id: string | null) => {
|
||||
setSelectedSessionId(id);
|
||||
// The drill-in sits far below the scatter; bring it into view.
|
||||
if (id)
|
||||
requestAnimationFrame(() =>
|
||||
drillInRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" })
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Auto-refresh on WebSocket events
|
||||
useEffect(() => {
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
const handler = (_msg: WSMessage) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(fetchData, 3000);
|
||||
};
|
||||
const unsub = eventBus.subscribe(handler);
|
||||
return () => {
|
||||
unsub();
|
||||
clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [fetchData]);
|
||||
// Auto-refresh on the WebSocket messages that change workflow data, throttled
|
||||
// so a steady stream refreshes every few seconds instead of never.
|
||||
useLiveRefresh(fetchData, {
|
||||
throttleMs: 5000,
|
||||
match: (msg: WSMessage) =>
|
||||
msg.type === "session_created" ||
|
||||
msg.type === "session_updated" ||
|
||||
msg.type === "agent_created" ||
|
||||
msg.type === "agent_updated" ||
|
||||
msg.type === "workflow_upserted",
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
@@ -151,6 +165,7 @@ export function Workflows() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
embedded={embedded}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -173,6 +188,7 @@ export function Workflows() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
embedded={embedded}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -196,6 +212,7 @@ export function Workflows() {
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
embedded={embedded}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -207,9 +224,13 @@ export function Workflows() {
|
||||
<WorkflowStats stats={data.stats} />
|
||||
|
||||
{/* Section 1: this dashboard's own pipelines */}
|
||||
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
|
||||
<LanePipelinePanel />
|
||||
</Section>
|
||||
{/* Pipelines are dormant by default: the section stays mounted (so it can
|
||||
report a template appearing) but hidden while none is loaded. */}
|
||||
<div className={hasPipelines ? undefined : "hidden"}>
|
||||
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
|
||||
<LanePipelinePanel onAvailability={setHasPipelines} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Section 2: what drives those pipelines */}
|
||||
<Section
|
||||
@@ -247,7 +268,7 @@ export function Workflows() {
|
||||
/>
|
||||
{selectedNode && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs text-fg-muted">{t("filteredBy")}</span>
|
||||
<span className="text-xs text-fg-muted">{t("selectedNode")}</span>
|
||||
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
|
||||
{selectedNode}
|
||||
</span>
|
||||
@@ -255,7 +276,7 @@ export function Workflows() {
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="text-xs text-fg-muted hover:text-fg-secondary underline"
|
||||
>
|
||||
{t("clearFilter")}
|
||||
{t("clearSelection")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +300,7 @@ export function Workflows() {
|
||||
subtitle={t("complexity.subtitle")}
|
||||
infoKey="complexity"
|
||||
>
|
||||
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
|
||||
<SessionComplexityScatter data={data.complexity} onSessionClick={selectSession} />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
@@ -293,18 +314,20 @@ export function Workflows() {
|
||||
</div>
|
||||
|
||||
{/* Section 7: Session Drill-In */}
|
||||
<Section
|
||||
number={7}
|
||||
title={t("drillIn.title")}
|
||||
subtitle={t("drillIn.subtitle")}
|
||||
infoKey="drillIn"
|
||||
>
|
||||
<SessionDrillIn
|
||||
sessionId={selectedSessionId}
|
||||
onClose={() => setSelectedSessionId(null)}
|
||||
onSelectSession={(id) => setSelectedSessionId(id)}
|
||||
/>
|
||||
</Section>
|
||||
<div ref={drillInRef} className="scroll-mt-4">
|
||||
<Section
|
||||
number={7}
|
||||
title={t("drillIn.title")}
|
||||
subtitle={t("drillIn.subtitle")}
|
||||
infoKey="drillIn"
|
||||
>
|
||||
<SessionDrillIn
|
||||
sessionId={selectedSessionId}
|
||||
onClose={() => setSelectedSessionId(null)}
|
||||
onSelectSession={(id) => setSelectedSessionId(id)}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -467,12 +490,15 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
|
||||
|
||||
// ── Page Header ──
|
||||
function PageHeader({
|
||||
embedded,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
onRefresh,
|
||||
onExport,
|
||||
lastUpdated,
|
||||
}: {
|
||||
/** Inside Dashboard → Analytics → Workflow: the tab bar already names it. */
|
||||
embedded?: boolean;
|
||||
statusFilter: StatusFilter;
|
||||
onStatusFilterChange: (f: StatusFilter) => void;
|
||||
onRefresh: () => void;
|
||||
@@ -488,8 +514,10 @@ function PageHeader({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 ${embedded ? "sm:justify-end" : "justify-between"}`}
|
||||
>
|
||||
<div className={embedded ? "hidden" : "flex items-center gap-3"}>
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<Workflow className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @file Sessions.urlState.test.tsx
|
||||
* @description The Sessions list keeps its filters in the URL: one filter
|
||||
* change is one load at page 0, "Waiting" asks the server (`awaiting`) instead
|
||||
* of downloading every active session, and a stale response never overwrites
|
||||
* a newer one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||
import { api } from "../../lib/api";
|
||||
import { Sessions } from "../Sessions";
|
||||
import type { Session } from "../../lib/types";
|
||||
|
||||
function GoPlain() {
|
||||
const nav = useNavigate();
|
||||
return <button onClick={() => nav("/sessions")}>go-plain</button>;
|
||||
}
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.search}</span>;
|
||||
}
|
||||
|
||||
const page = (sessions: Partial<Session>[], total = sessions.length) => ({
|
||||
sessions: sessions as Session[],
|
||||
total,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(api.sessions, "facets").mockResolvedValue({ cwds: [] } as never);
|
||||
vi.spyOn(api.remoteSources, "list").mockResolvedValue({ sources: [] } as never);
|
||||
vi.spyOn(api.run, "list").mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
describe("Sessions URL state", () => {
|
||||
it("one filter click = one load at page 0; Waiting asks the server", async () => {
|
||||
const list = vi.spyOn(api.sessions, "list").mockResolvedValue(page([]));
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/sessions?page=3"]}>
|
||||
<Sessions />
|
||||
<Where />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(list).toHaveBeenCalled());
|
||||
expect(list.mock.calls[list.mock.calls.length - 1]![0]).toMatchObject({ offset: 30 });
|
||||
list.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Waiting" }));
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||
expect(list.mock.calls[0]![0]).toMatchObject({
|
||||
status: "active",
|
||||
awaiting: true,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
});
|
||||
expect(screen.getByTestId("where").textContent).toBe("?status=waiting");
|
||||
});
|
||||
|
||||
it("drops a response that arrives after a newer one", async () => {
|
||||
let resolveOld!: (v: ReturnType<typeof page>) => void;
|
||||
const list = vi
|
||||
.spyOn(api.sessions, "list")
|
||||
.mockImplementationOnce(() => new Promise((r) => (resolveOld = r)))
|
||||
.mockResolvedValue(
|
||||
page([
|
||||
{ id: "new-1", name: "Fresh", status: "completed", started_at: "2026-01-01T00:00:00Z" },
|
||||
])
|
||||
);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/sessions"]}>
|
||||
<Sessions />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Completed" }));
|
||||
expect(await screen.findByText("Fresh")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
resolveOld(
|
||||
page([
|
||||
{ id: "old-1", name: "Stale", status: "completed", started_at: "2026-01-01T00:00:00Z" },
|
||||
])
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText("Stale")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("an outside navigation that clears ?q= clears the box, and the old q is not written back", async () => {
|
||||
vi.spyOn(api.sessions, "list").mockResolvedValue(page([]));
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/sessions?q=abc"]}>
|
||||
<Sessions />
|
||||
<GoPlain />
|
||||
<Where />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: /search sessions/i })).toHaveValue("abc");
|
||||
fireEvent.click(screen.getByText("go-plain"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox", { name: /search sessions/i })).toHaveValue("")
|
||||
);
|
||||
await act(() => new Promise((r) => setTimeout(r, 400)));
|
||||
expect(screen.getByTestId("where").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("a page past the end goes back to page 1", async () => {
|
||||
vi.spyOn(api.sessions, "list").mockResolvedValue(page([], 5));
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/sessions?page=7"]}>
|
||||
<Sessions />
|
||||
<Where />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("where").textContent).toBe(""));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file ui-audit-routes.test.js
|
||||
* @description Contract tests for the read-route additions the dashboard UI audit
|
||||
* needed: batched agents by `session_ids`, exact tool-call totals on session
|
||||
* stats, session/agent labels joined onto the events list, and the
|
||||
* `awaiting`/`from`/`to` filters on the sessions list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
const TEST_DB = path.join(os.tmpdir(), `ui-audit-test-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
async function get(p) {
|
||||
const res = await fetch(BASE + p);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, started_at, awaiting_input_since) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
s.run("ua-1", "First", "active", "/tmp/ua", "2026-01-10T10:00:00.000Z", "2026-01-10T10:05:00Z");
|
||||
s.run("ua-2", "Second", "completed", "/tmp/ua", "2026-02-10T10:00:00.000Z", null);
|
||||
s.run("ua-3", "Third", "active", "/tmp/ua", "2026-03-10T10:00:00.000Z", null);
|
||||
const a = db.prepare(
|
||||
"INSERT INTO agents (id, session_id, name, type, subagent_type) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
a.run("ua-1-main", "ua-1", "Main", "main", null);
|
||||
a.run("ua-2-main", "ua-2", "Main", "main", null);
|
||||
a.run("ua-2-sub", "ua-2", "Explorer", "subagent", "Explore");
|
||||
a.run("ua-3-main", "ua-3", "Main", "main", null);
|
||||
const e = db.prepare(
|
||||
"INSERT INTO events (session_id, agent_id, event_type, tool_name) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
// ua-1: 20 distinct tools, one PreToolUse + one PostToolUse each.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
e.run("ua-1", "ua-1-main", "PreToolUse", `Tool${i}`);
|
||||
e.run("ua-1", "ua-1-main", "PostToolUse", `Tool${i}`);
|
||||
}
|
||||
// ua-2: imported shape, PostToolUse only.
|
||||
e.run("ua-2", "ua-2-sub", "PostToolUse", "Read");
|
||||
e.run("ua-2", "ua-2-sub", "PostToolUse", "Read");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (server) server.close();
|
||||
if (db) db.close();
|
||||
for (const f of [TEST_DB, TEST_DB + "-wal", TEST_DB + "-shm"]) {
|
||||
try {
|
||||
fs.unlinkSync(f);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /api/agents?session_ids=", () => {
|
||||
it("returns every agent of the named sessions in one call", async () => {
|
||||
const { status, body } = await get("/api/agents?session_ids=ua-1,ua-2");
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.agents.map((a) => a.id).sort(), ["ua-1-main", "ua-2-main", "ua-2-sub"]);
|
||||
});
|
||||
it("an empty list returns no agents", async () => {
|
||||
const { body } = await get("/api/agents?session_ids=");
|
||||
assert.deepEqual(body.agents, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions/:id/stats tool totals", () => {
|
||||
it("counts every PreToolUse, not the top-15 list", async () => {
|
||||
const { body } = await get("/api/sessions/ua-1/stats");
|
||||
assert.equal(body.tools_used.length, 15);
|
||||
assert.equal(body.tool_call_count, 20);
|
||||
assert.equal(body.distinct_tool_count, 20);
|
||||
});
|
||||
it("falls back to PostToolUse when a session has no PreToolUse", async () => {
|
||||
const { body } = await get("/api/sessions/ua-2/stats");
|
||||
assert.equal(body.tool_call_count, 2);
|
||||
assert.equal(body.distinct_tool_count, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/events labels", () => {
|
||||
it("joins session name/cwd and agent name/type onto each row", async () => {
|
||||
const { body } = await get("/api/events?session_id=ua-2");
|
||||
assert.equal(body.total, 2);
|
||||
const ev = body.events[0];
|
||||
assert.equal(ev.session_name, "Second");
|
||||
assert.equal(ev.session_cwd, "/tmp/ua");
|
||||
assert.equal(ev.agent_name, "Explorer");
|
||||
assert.equal(ev.agent_subagent_type, "Explore");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions filters", () => {
|
||||
it("awaiting=1 keeps only active sessions waiting on the user", async () => {
|
||||
const { body } = await get("/api/sessions?awaiting=1&q=ua-");
|
||||
assert.deepEqual(
|
||||
body.sessions.map((s) => s.id),
|
||||
["ua-1"]
|
||||
);
|
||||
assert.equal(body.total, 1);
|
||||
});
|
||||
it("from/to bound started_at", async () => {
|
||||
const { body } = await get(
|
||||
"/api/sessions?q=ua-&from=2026-02-01T00:00:00Z&to=2026-02-28T23:59:59Z"
|
||||
);
|
||||
assert.deepEqual(
|
||||
body.sessions.map((s) => s.id),
|
||||
["ua-2"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1960,6 +1960,14 @@ const stmts = {
|
||||
GROUP BY event_type
|
||||
ORDER BY count DESC
|
||||
`),
|
||||
sessionToolCallTotals: db.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN event_type = 'PreToolUse' THEN 1 ELSE 0 END) as pre,
|
||||
SUM(CASE WHEN event_type = 'PostToolUse' THEN 1 ELSE 0 END) as post,
|
||||
COUNT(DISTINCT tool_name) as distinct_tools
|
||||
FROM events
|
||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||
`),
|
||||
sessionToolUsageCounts: db.prepare(`
|
||||
SELECT tool_name, COUNT(*) as count
|
||||
FROM events
|
||||
|
||||
+19
-1
@@ -19,8 +19,26 @@ router.get("/", (req, res) => {
|
||||
const session_id = req.query.session_id;
|
||||
const sources = parseSources(req);
|
||||
|
||||
// `session_ids=a,b,c` = every agent of those sessions in ONE call (the
|
||||
// Dashboard's active trees), capped so the IN list stays small.
|
||||
const sessionIds =
|
||||
typeof req.query.session_ids === "string"
|
||||
? [...new Set(req.query.session_ids.split(",").map((s) => s.trim()))]
|
||||
.filter(Boolean)
|
||||
.slice(0, 200)
|
||||
: null;
|
||||
|
||||
let rows;
|
||||
if (session_id) {
|
||||
if (sessionIds) {
|
||||
rows = sessionIds.length
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT * FROM agents WHERE session_id IN (${sessionIds.map(() => "?").join(",")})
|
||||
ORDER BY started_at DESC LIMIT ?`
|
||||
)
|
||||
.all(...sessionIds, limit)
|
||||
: [];
|
||||
} else if (session_id) {
|
||||
// A session belongs to exactly one source, so no extra source filter needed.
|
||||
rows = stmts.listAgentsBySession.all(session_id);
|
||||
} else if (sources) {
|
||||
|
||||
@@ -102,7 +102,15 @@ router.get("/", (req, res) => {
|
||||
|
||||
const { sql: whereSql, params: whereParams } = buildWhere(filters);
|
||||
|
||||
const listSql = `SELECT * FROM events ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`;
|
||||
// Labels for the row (session name/cwd, agent name/type) come with it, so a
|
||||
// list page never downloads every session and agent just to name them.
|
||||
const listSql = `SELECT events.*,
|
||||
(SELECT name FROM sessions WHERE id = events.session_id) as session_name,
|
||||
(SELECT cwd FROM sessions WHERE id = events.session_id) as session_cwd,
|
||||
(SELECT lane_id FROM sessions WHERE id = events.session_id) as session_lane_id,
|
||||
(SELECT name FROM agents WHERE id = events.agent_id) as agent_name,
|
||||
(SELECT subagent_type FROM agents WHERE id = events.agent_id) as agent_subagent_type
|
||||
FROM events ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`;
|
||||
const countSql = `SELECT COUNT(*) as count FROM events ${whereSql}`;
|
||||
|
||||
const events = db.prepare(listSql).all(...whereParams, limit, offset);
|
||||
|
||||
@@ -193,6 +193,23 @@ router.get("/", (req, res) => {
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
// `awaiting=1` = only sessions blocked on the user (an active session with
|
||||
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
||||
if (req.query.awaiting === "1") {
|
||||
where.push("s.awaiting_input_since IS NOT NULL AND s.status = 'active'");
|
||||
}
|
||||
// `from`/`to` (ISO) bound `started_at`; an unparseable value is ignored.
|
||||
for (const [key, op] of [
|
||||
["from", ">="],
|
||||
["to", "<="],
|
||||
]) {
|
||||
const raw = req.query[key];
|
||||
const d = typeof raw === "string" && raw ? new Date(raw) : null;
|
||||
if (d && !Number.isNaN(d.getTime())) {
|
||||
where.push(`s.started_at ${op} ?`);
|
||||
params.push(d.toISOString());
|
||||
}
|
||||
}
|
||||
// `archived=0|1` = only live / only archived; absent = both, as before.
|
||||
if (req.query.archived === "0") where.push("s.archived_at IS NULL");
|
||||
if (req.query.archived === "1") where.push("s.archived_at IS NOT NULL");
|
||||
@@ -330,6 +347,9 @@ router.get("/:id/stats", (req, res) => {
|
||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
||||
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
||||
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
||||
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
||||
const errors = stmts.sessionErrorCount.get(sessionId)?.count ?? 0;
|
||||
const timeRange = stmts.sessionEventTimeRange.get(sessionId) || {};
|
||||
const subagentTypes = stmts.sessionAgentTypeCounts.all(sessionId);
|
||||
@@ -370,6 +390,8 @@ router.get("/:id/stats", (req, res) => {
|
||||
total_events: totalEvents,
|
||||
events_by_type: eventsByType,
|
||||
tools_used: tools,
|
||||
tool_call_count: toolTotals.pre > 0 ? toolTotals.pre : (toolTotals.post ?? 0),
|
||||
distinct_tool_count: toolTotals.distinct_tools ?? 0,
|
||||
error_count: errors,
|
||||
first_event_at: timeRange.first_at ?? null,
|
||||
last_event_at: timeRange.last_at ?? null,
|
||||
|
||||
Reference in New Issue
Block a user